ZPL 旋转打印:把 150mm×100mm 的标签打上 100mm×150mm 的标签纸上

# ZPL 旋转打印:把 150mm×100mm 的标签打上 100mm×150mm 的标签纸上

一、问题

外箱标签的版式是 150×100mm 横版,但现场只有 100×150mm 的竖版标签纸,打印机也换不了纸。

横版长边 150mm 超过纸宽 100mm,直接下发的后果是:内容顶到纸边被裁掉,右侧的 PKG ID 长条码和 REV 分区消失大半。这不是排版细节问题,是物理上装不下。

可行的做法只有三种:

  1. **缩放**:把 150mm 压到 100mm,缩放比 0.67。条码模块宽度随之缩小,Code128 在 300dpi 下模块宽度本来就只有 2~3 dots,再乘 0.67 就低于可识别阈值,扫码枪读不出来。

  2. **重画版式**:按 100×100mm 重新设计,等于放弃原有分区(PKG ID / DP/N / BOX QTY / Mfg ID / REV)的固定位置,客户不认。

  3. **整版旋转 90° 打到竖纸上**,贴标时把标签逆时针转回来。

本文只讲第 3 种:ZPL 层怎么做、坐标系到底怎么变、以及怎么用像素比对证明自己没写错。所有数据都是在真实标签上跑出来的,可以直接复现。

二、先搞清楚:ZPL 的"旋转"是字段级的,不是画布级的

新手最容易踩的第一个坑,是以为存在一条"把整张标签转 90°"的指令。

ZPL 里没有画布旋转。它只有**字段方向**:

| 指令 | 作用范围 | 取值 |

|---|---|---|

| `^FW` | 影响**后续**所有字段的默认方向 | `N` 正常 / `R` 顺时针 90° / `I` 倒置 180° / `B` 逆时针 90° |

| `^A0R` | 只影响该文本字体 | 同上,作为 `^A` 的第 2 个字符 |

| `^BCR` | 只影响该条码 | 同上,作为 `^BC` 的第 1 个参数 |

| `^GB` | **不受影响**,永远按绝对坐标画 | 没有方向参数 |

第二**坑紧接着就来:**`^A` 和 `^BC` 自带的方向参数会覆盖 `^FW`**。**下面两段只差一条字体指令:

```

^FWR

^FO200,50^A0N,40,40^FDHELLO^FS

```

```

^FWR

^FO200,50^CF0,40,40^FDHELLO^FS

```

前者里的 `^A0N` 把方向钉死成横向,`^FWR` 等于白设;后者用的 `^CF` 没有方向参数,才会老老实实继承 `^FWR`。两段的渲染差异见 3.1 节的像素坐标。

`^BC` 的规则还要绕一点:方向参数在参数表里是第一位,**留空才继承 `^FW`,写 `N` 就是强制不转**。

```

^FO840,90^BC,225,N,N,N,N^FD3S42940...^FS

```

```

^FO840,90^BCN,225,N,N,N,N^FD3S42940...^FS

```

上面第一段是正确写法(方向位为空),第二段强制 `N`,条码会横着躺到外框之外。

三、`^FWR` 之后,坐标系到底往哪边生长

这是全文的核心。网上多数文章只写一句"`^FWR` 把内容顺时针旋转 90°",但没人告诉你**旋转之后 `^FO` 的 x、y 分别对应什么**。凭直觉猜,十有八九会把元素放到纸外。

3.1 实测:同一段文字,四种写法

测试条件:40×40mm 标签,12 dpmm 渲染(≈304.8dpi),画布 478×478 像素;文本 `HELLO`,字高 40 dots。表中的 bbox 是渲染后**墨迹**的像素包围盒 `(左, 上, 右, 下)`。

| ZPL 片段 | 墨迹 bbox | 说明 |

|---|---|---|

| `^FWR` + `^FO200,50` + `^CF0,40,40` | `(209, 53, 241, 154)` | 竖排:高 32、长 101 |

| `^FWN` + `^FO200,50` + `^CF0,40,40` | `(203, 49, 304, 81)` | 横排:长 101、高 32 |

| `^FO200,50` + `^A0R,40,40` | `(209, 53, 241, 154)` | **与第一行完全相同** |

| `^FWR` + `^FO200,150` + `^CF0,40,40` | `(209, 153, 241, 254)` | y 加大 100,墨迹整体下移 100 |

从这张表能读出三条结论:

  1. **竖排时,字高方向沿 +x 生长**,内容前进方向沿 +y 生长。锚点 `(200,50)` 落在墨迹的左上角附近(+9, +3 是字形留白)。

  2. `^A0R,40,40` 与 `^FWR` + `^CF0,40,40` **像素级等价**,可以互换。这一条很实用:`^CF` 没有方向参数,想把某一段文字单独竖排而不影响其他字段,就得改用 `^A0R`。

  3. `^FO` 的 x、y 始终是**纸张物理坐标**,不随 `^FW` 旋转。所以竖纸上写版式,x 是纸的宽方向(100mm),y 是纸的高方向(150mm)。

3.2 方框为什么"不跟着转"

```

^FWR ^FO20,30^GB100,50,3^FS → 墨迹 bbox (20, 30, 120, 80)

^FWN ^FO20,30^GB100,50,3^FS → 墨迹 bbox (20, 30, 120, 80)

```

完全一致。因为 `^GB` 根本没有方向参数,它按 `^FO` 的绝对坐标画宽 100、高 50 的框。

这带来一个反直觉的后果:**旋转版里的分隔线要自己按转置后的位置写**。横版里的一根竖线,到竖纸上还是竖线------但它在纸上的"竖"对应阅读时的"横"。样本里那三根线(`^FO420,10^GB0,1090,3`、`^FO800,10^GB0,1090,3`、`^FO10,1100^GB1130,0,3`)看着是两根竖线一根横线,逆时针转 90° 之后正好变成横版的两根横线一根竖线。

3.3 换算公式

把横版坐标映射到竖纸,实测成立的公式是:

```

x_竖纸 = 纸宽 - (Y_横版 + h) ; h = 元素在横版里的纵向高度

y_竖纸 = X_横版

```

其中"纸宽"指竖纸的宽度(dots),`h` 是文本字高、条码高度或方框高度。

验证方式很直接:把竖排渲染结果**逆时针旋转 90°**,再和横版渲染结果比包围盒。

```

竖纸 ^FWR ^FO200,50 ^CF0,40,40 → 墨迹 (209, 53, 241, 154)

逆时针转 90° → (53, 237, 154, 269)

横版 ^FWN ^FO50,238 ^CF0,40,40 → (53, 237, 154, 269) ← 完全一致

```

`238 = 478 - 200 - 40`,正是公式里的 `纸宽 - x - h`。三个数字对得上,误差 0。

四、完整样本

下面是一条真实的 DELL 外箱标签,300dpi,竖纸 100×150mm,打印后逆时针转 90° 贴箱。为便于阅读,`^FX` 注释全部写成不含 `^` 的纯文本(原因见第七节第一个坑)。

```

^XA

^FX DELL outer carton label, rotated print

^FX Target label 150x100mm landscape; printer media 100x150mm portrait.

^FX FWR rotates field content 90 degrees so portrait media prints a landscape label.

^FX 300dpi; outer box GB1130,1700,3 is about 96x144mm.

^LH35,25

^PON

^FWR

^BY2,2^FS

^FO10,10^GB1130,1700,3^FS

^FO420,10^GB0,1090,3^FS

^FO800,10^GB0,1090,3^FS

^FO10,1100^GB1130,0,3^FS

^FO1080,90^CF0,45,45^FDPKG ID(3S) 42940FCH0064T0000026012B1504^FS

^FO840,90^BC,225,N,N,N,N^FD3S42940FCH0064T0000026012B1504^FS

^BY3,2^FS

^FO700,90^CF0,45,45^FDDP/N^FS

^FO700,300^CF0,45,45^FD6012B1^FS

^FO450,250^BC,225,N,N,N,N^FD6012B1^FS

^FO350,90^CF0,45,45^FDBOX QTY^FS

^FO280,90^CF0,45,45^FD504^FS

^FO35,90^BC,225,N,N,N,N^FD504^FS

^FO350,660^CF0,45,45^FDMade in ^FS

^FO280,680^CF0,45,45^FDChina^FS

^FO35,660^BC,225,N,N,N,N^FDCN^FS

^FO580,1220^BC,225,N,N,N,N^FD42940|42943^FS

^FO500,1250^CF0,45,45^FDVendor ID | Loc ID^FS

^FO440,1250^CF0,45,45^FD42940|42943^FS

^FO170,1300^BC,225,N,N,N,N^FDFCH00^FS

^FO90,1330^CF0,45,45^FDMfg ID^FS

^FO30,1330^CF0,45,45^FDFCH00^FS

^FO900,1300^BC,225,N,N,N,N^FDA^FS

^FO820,1330^CF0,45,45^FDREV A^FS

^FWN

^XZ

```

注意最后没有 `^JUS`。很多样本会在结尾写 `^FWN^JUS`,`^JUS` 是把当前设置写进打印机非易失存储。如果这张标签每天打几百张,每条任务都写一次 EEPROM 并不合适,只留 `^FWN` 复位就够了;只有在需要**掉电后仍保持**某个设置时才用 `^JUS`。

五、逐条指令说明

| 指令 | 作用 | 为什么必须这么写 |

|---|---|---|

| `^PON` | 打印方向正常,不倒置、不镜像 | 省略时行为取决于打印机上次保存的设置,贴标方向会时对时错 |

| `^FWR` | 后续字段内容顺时针旋转 90° | 全文核心,只影响字段,不影响 `^FO` / `^GB` |

| `^LH35,25` | 原点偏移 35×25 dots ≈ 3.0×2.1mm | 给外框留边距,否则外框贴纸边,热敏边缘容易烧不匀 |

| `^FO10,10^GB1130,1700,3` | 外框 1130×1700 dots ≈ 95.7×143.9mm | 竖框;转 90° 后变成 143.9×95.7mm 的横框,正好落在 150×100mm 内 |

| `^CF0,45,45` | 默认字体 45 dots | `^CF` **没有方向参数**,所以能继承 `^FWR`;换成 `^A0N` 就前功尽弃 |

| `^BC,225,N,N,N,N` | Code128,高 225 dots,不打印解释行 | **第一个参数必须留空**才会继承 `^FWR`;显式写方向是绝对值,不会叠加------实测 `^FWR` + `^BCR` 与单独 `^BCR` 渲染 0 差异 |

| `^BY2,2` / `^BY3,2` | 模块宽度与宽窄比 | 长条码用 2 保证塞得下,短码用 3 提高扫码容错 |

| `^FWN` | 恢复默认字段方向 | 不加的话旋转状态会传给下一个打印任务 |

`^BC` 那个"留空"是这条样本里最隐蔽的地方。我把 7 处 `^BC,` 全部改成 `^BCN,` 重新渲染,和原文比对:**差异 310,950 像素**,条码全部横躺到外框之外。

六、尺寸换算

300dpi 下 1mm ≈ 11.81 dots(203dpi 是 8 dots/mm,把下表左列乘 0.677 即可)。

| 项 | dots | mm | 说明 |

|---|---|---|---|

| 纸张 100×150 | 1181 × 1772 | 100 × 150 | 由介质校准给出,不写进 ZPL |

| 外框 `^GB1130,1700,3` | 1130 × 1700 | 95.7 × 143.9 | 竖框形态 |

| 原点 `^LH35,25` + `^FO10,10` | 45 × 35 | 3.8 × 3.0 | 左 / 上边距 |

| 外框右下角 | 1175 × 1735 | 99.5 × 146.9 | 右剩 0.5mm,下剩 3.1mm |

| 逆时针转 90° 后 | 1700 × 1130 | 143.9 × 95.7 | 落在 150×100 的可用区,四边余量 3~4mm |

这里有个刻意的选择:**不写 `^PW` / `^LL`**。这两条是强制纸宽和标签长度(dots),一旦写死,换纸型就得改指令;交给打印机介质校准更灵活。代价是打印机必须先做过校准,如果现场打印机没校准、打完走纸异常,再加 `^PW1181^LL1772` 兜底。

不旋转会怎样?把样本里的 `^FWR` 换成 `^FWN` 渲染,墨迹右边界从 1175 顶到 **1193**(画布只有 1196),等于用 100mm 的纸装 150mm 的内容,右侧 PKG ID 和 REV 分区直接被切掉。

七、四个坑,都有实测数字

坑 1:`^FX` 注释里出现 `^GB`,会多画一个框

```

^FX ^GB1130,1700,3

```

本意是注释,但部分解析器(Labelary 就是)会把注释里的 `^GB...` 当成指令执行。实测差异 **16,917 像素**------纸上凭空多出一个 1130×1700 的框。

更危险的是 `^FX ^XZ`:注释里的 `^XZ` 会直接截断标签,渲染返回 404、整张空白。

对照测试,注释里写 `^FO`、`^XA`、`^PQ` 都无害,只有绘图类和结束符类指令会被误解析。所以规矩很简单:**`^FX` 注释里一个 `^` 都不要有**,需要提到指令名就写成 `FWR`、`GB`。

坑 2:`^FO` 的 x 贴在右边缘,旋转后会被裁

因为字高沿 **+x** 生长,`^FO1080,90` 配 45 dots 字高,墨迹占到 1080~1125,勉强在 1140 的外框内。如果按横排习惯写成 `^FO1140,90` 想让文字右对齐,字会整个跑到纸外。

竖排时 x 是"起点"不是"终点",**右对齐要自己算 `纸宽 - 字高 - 右边距`**。

坑 3:`^FW` 的状态会漏到下一个任务

`^FWR` 之后如果没写 `^FWN`,同一台打印机紧接着打的下一张标签(哪怕是另一个系统下发的)会继续竖排。共享打印机场景下这是最容易出的事故,而且现象诡异------"昨天还好好的,今天就全歪了"。

坑 4:`^A` 的显式方向参数覆盖 `^FW`

前面提过,这里单独列出来,因为它在**改造老模板**时最致命:老模板里往往混着 `^A0N` 和 `^CF`,你加了 `^FWR` 之后,用 `^CF` 的那部分转了,用 `^A0N` 的那部分没转,出来是一张一半横一半竖的标签。改造前先全文搜一遍 `^A\dNRIB` 和 `^BCNRIB`。

八、怎么证明你没写错:渲染 + 逐像素比对

ZPL 是"写完才知道对不对"的语言,靠肉眼盯代码基本不可能发现 1~2 个参数写错。可靠办法是渲染成图再比像素。

Labelary 提供免费的在线渲染 API,按 dpmm 和标签尺寸请求,返回 PNG:

```bash

curl -X POST "http://api.labelary.com/v1/printers/12dpmm/labels/3.937x5.906/0/" \

-H "Accept: image/png" \

--data-binary @sample.zpl -o sample.png

```

`3.937x5.906` 英寸就是 100×150mm,`12dpmm` ≈ 304.8dpi,和 300dpi 打印机足够接近。

有了图,比对就变成一个纯 Python 的活。**拿改造后的版本和线上真实下发的原文比,差异必须是 0**:

```python

import io, urllib.request

from PIL import Image, ImageChops

def render(zpl_bytes, w=3.937, h=5.906, dpmm=12):

url = f"http://api.labelary.com/v1/printers/{dpmm}dpmm/labels/{w}x{h}/0/"

req = urllib.request.Request(url, data=zpl_bytes, method="POST")

req.add_header("Accept", "image/png")

return Image.open(io.BytesIO(urllib.request.urlopen(req, timeout=60).read())).convert("L")

a = render(open("orig.zpl", "rb").read()) # 线上原文

b = render(open("sample.zpl", "rb").read()) # 改造后

diff = ImageChops.difference(a, b)

print("画布", a.size, b.size)

print("差异 bbox", diff.getbbox(), "差异像素", sum(1 for p in diff.get_flattened_data() if p))

```

本文样本的实测结果:

| 项 | 结果 |

|---|---|

| 画布 | 1196 × 1795(100×150mm @ 12dpmm) |

| 墨迹包围盒 | `(45, 35, 1175, 1735)`,即 3.8~99.5mm × 3.0~146.9mm |

| 与历史下发原文比对 | **差异 0 像素** |

差异为 0 意味着改造没有动任何一个点的位置,只多了注释和 `^FWN` 复位。

比对时有个细节要注意:**必须挑对参照记录**。同一个标签编码在数据库里存着多个历史版本,我一开始随手取了 06-17 那条,差异 85,588 像素,检查半天以为是自己写错了------其实是那天的版式本来就不同。参照记录要取和样本同一次打印(本例是 06-23 10:41:57 那条)。

九、整版旋转还是逐元素旋转

两种做法都能实现同样效果,选错会白干一遍。

| | 整版 `^FWR` | 逐元素 `^A0R` / `^BCR` |

|---|---|---|

| 改动量 | 老 ZPL 加两行(`^FWR` 开头、`^FWN` 结尾) | 每个元素都要改方向参数 |

| 坐标 | 保持纸张物理坐标,可直接照竖纸画 | 每个元素按自己的包围盒单独换算 |

| 字体指令 | 必须用 `^CF`(无方向参数);`^A` / `^BC` 里任何显式方向位都会覆盖 `^FWR` | 必须用 `^A` 或 `^BC` 带方向位,`^CF` 无法指定方向 |

| 风险点 | 混进一个 `^BCN` 就整张翻车 | 漏改一个元素,只有那一块不转 |

| 适用 | 存量 ZPL 迁移、一次性改造 | 新模板、可视化设计器(每个元素独立方向) |

判断标准很简单:**如果标签已经有现成的 ZPL、且不想动版式,用整版旋转;如果是新建模板、元素坐标由设计器管理,用逐元素旋转**。后者在数据结构上更干净------元素只要多存一个 `orientation` 字段,不用为旋转单独维护一套转置后的坐标。

十、小结

  • ZPL 没有画布旋转,只有字段方向;`^FWR` 影响后续字段,不影响 `^FO` / `^GB`。

  • `^A`、`^BC` 自带的方向参数优先级高于 `^FW`,`^BC` 要继承就必须把方向位留空。

  • 竖排时 x 承载"字高"、y 承载"内容前进",`^FO` 的 x 是起点不是终点。

  • 换算公式 `x = 纸宽 - (Y + h)`、`y = X`,逆时针旋转 90° 后可验证到像素级一致。

  • 验证靠渲染加逐像素比对,差异必须是 0;参照记录要挑同一次打印。

  • `^FX` 注释里不要出现任何 `^`。

文中样本用到的完整 ZPL 与渲染比对脚本放在这个仓库里:<https://gitee.com/afeng124/coolcodetrace\>(`docs/samples/BC-DELL-01-rotated.zpl`),需要复现可以直接取。