From e5c51b8c30f94706e766eb38890881252ab0f45f Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 17 Aug 2026 20:10:15 +0200 Subject: [PATCH] fix(zpl): anchor rotated device-font fields by the cell-grid extent I/B and right-justified bitmap fields used PrintLab's proportional width; the firmware extends from the cell edge by n*advance - gap/2 (Labelary-measured). --- packages/core/src/lib/emittedAnchor.ts | 2 +- .../core/src/lib/labelGeometry/deviceFonts.ts | 52 ++++++++++++++---- .../lib/labelGeometry/textRenderMetrics.ts | 28 +++++++++- packages/core/src/lib/objectBounds.ts | 4 ++ packages/core/src/lib/zplParser/flushField.ts | 23 ++++---- packages/core/src/registry/text.ts | 4 +- packages/core/src/registry/zplHelpers.ts | 24 ++++++-- packages/mcp-server/src/tools.test.ts | 5 ++ packages/mcp-server/src/tools.ts | 5 +- src/components/Canvas/LabelCanvas.tsx | 2 +- src/lib/labelGeometry/deviceFonts.test.ts | 19 ++++++- src/lib/zplParser.test.ts | 18 ++++++ src/test/deviceFontBoxMatch.test.ts | 18 ++++-- tests/fixtures/deviceFontBoxMatchCases.ts | 23 +++++++- .../labelary_devicefont_images/fA_rotB_m1.png | Bin 0 -> 6570 bytes .../labelary_devicefont_images/fA_rotB_m2.png | Bin 0 -> 6709 bytes .../labelary_devicefont_images/fA_rotI_m1.png | Bin 0 -> 6578 bytes .../labelary_devicefont_images/fA_rotI_m2.png | Bin 0 -> 6738 bytes .../labelary_devicefont_images/fE_rotB_m1.png | Bin 0 -> 6892 bytes .../labelary_devicefont_images/fE_rotB_m2.png | Bin 0 -> 7313 bytes .../labelary_devicefont_images/fE_rotI_m1.png | Bin 0 -> 6947 bytes .../labelary_devicefont_images/fE_rotI_m2.png | Bin 0 -> 7491 bytes .../labelary_devicefont_images/fG_rotB_m1.png | Bin 0 -> 7255 bytes .../labelary_devicefont_images/fG_rotB_m2.png | Bin 0 -> 7923 bytes .../labelary_devicefont_images/fG_rotI_m1.png | Bin 0 -> 7437 bytes .../labelary_devicefont_images/fG_rotI_m2.png | Bin 0 -> 8141 bytes 26 files changed, 184 insertions(+), 43 deletions(-) create mode 100644 tests/fixtures/labelary_devicefont_images/fA_rotB_m1.png create mode 100644 tests/fixtures/labelary_devicefont_images/fA_rotB_m2.png create mode 100644 tests/fixtures/labelary_devicefont_images/fA_rotI_m1.png create mode 100644 tests/fixtures/labelary_devicefont_images/fA_rotI_m2.png create mode 100644 tests/fixtures/labelary_devicefont_images/fE_rotB_m1.png create mode 100644 tests/fixtures/labelary_devicefont_images/fE_rotB_m2.png create mode 100644 tests/fixtures/labelary_devicefont_images/fE_rotI_m1.png create mode 100644 tests/fixtures/labelary_devicefont_images/fE_rotI_m2.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_rotB_m1.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_rotB_m2.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_rotI_m1.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_rotI_m2.png diff --git a/packages/core/src/lib/emittedAnchor.ts b/packages/core/src/lib/emittedAnchor.ts index 2b821dc0..417468ea 100644 --- a/packages/core/src/lib/emittedAnchor.ts +++ b/packages/core/src/lib/emittedAnchor.ts @@ -18,7 +18,7 @@ export function emittedAnchorDots( box?: BoundingBoxDots, ): { x: number; y: number } { if (obj.type === "text") { - const { x, y } = textZplAnchorCoords(obj as Parameters[0], ctx.label); + const { x, y } = textZplAnchorCoords(obj as Parameters[0], ctx.label, ctx.variables); return { x, y }; } if (GRAPHIC_ANCHOR_TYPES.has(obj.type)) { diff --git a/packages/core/src/lib/labelGeometry/deviceFonts.ts b/packages/core/src/lib/labelGeometry/deviceFonts.ts index b5236654..99d717b4 100644 --- a/packages/core/src/lib/labelGeometry/deviceFonts.ts +++ b/packages/core/src/lib/labelGeometry/deviceFonts.ts @@ -67,6 +67,41 @@ export const DEVICE_FONT_CELLS: Readonly< ]), ); +/** Snapped magnifications for a bitmap font, or null for Font 0 / unknown + * ids / non-positive heights (magnify would otherwise clamp a negative to + * mag 1 or propagate NaN). Width 0 derives from the height mag. */ +function deviceFontMags( + fontId: string | undefined, + heightDots: number, + widthDots: number, +): { spec: DeviceFontSpec; magH: number; magW: number } | null { + if (!fontId) return null; + const spec = DEVICE_FONTS[fontId]; + if (!spec || !(heightDots > 0)) return null; + const magH = magnify(heightDots, spec.magStep); + const magW = widthDots > 0 ? magnify(widthDots, spec.magWidthStep) : magH; + return { spec, magH, magW }; +} + +/** Deterministic field extent in dots for the ^FO anchor of rotated (I/B) + * bitmap fields: cells are monospaced, and Labelary measures the printed + * extent at n*advance - gap/2 within 2 dots across fonts. Null for + * Font 0 / unknown ids (measured PrintLab width applies there). */ +export function deviceFontInkWidthDots( + fontId: string | undefined, + heightDots: number, + widthDots: number, + content: string, +): number | null { + const mags = deviceFontMags(fontId, heightDots, widthDots); + if (!mags) return null; + const { spec, magW } = mags; + const n = applyDeviceFontCase(fontId, content).length; + if (n === 0) return 0; + const gap = spec.advancePerMag - spec.magWidthStep; + return magW * (n * spec.advancePerMag - gap / 2); +} + /** Requested ^A height snapped to the cell grid, or null for Font 0 / * unknown ids / non-positive heights. The firmware anchors a bitmap field * by its snapped cell, so the anchor transform needs this too. */ @@ -74,10 +109,8 @@ export function deviceFontSnappedHeightDots( fontId: string | undefined, heightDots: number, ): number | null { - if (!fontId) return null; - const spec = DEVICE_FONTS[fontId]; - if (!spec || !(heightDots > 0)) return null; - return magnify(heightDots, spec.magStep) * spec.magStep; + const mags = deviceFontMags(fontId, heightDots, 0); + return mags ? mags.magH * mags.spec.magStep : null; } // Zebra fonts B and H (OCR-A) have no lowercase glyphs: B prints uppercase, @@ -119,14 +152,9 @@ export function deviceFontMetrics( heightDots: number, widthDots: number, ): DeviceFontMetrics | null { - if (!fontId) return null; - const spec = DEVICE_FONTS[fontId]; - if (!spec) return null; - // Guard NaN / non-positive height: magnify would otherwise clamp a negative - // to mag 1 or propagate NaN through fontSizeDots into every offset. - if (!(heightDots > 0)) return null; - const magH = magnify(heightDots, spec.magStep); - const magW = widthDots > 0 ? magnify(widthDots, spec.magWidthStep) : magH; + const mags = deviceFontMags(fontId, heightDots, widthDots); + if (!mags) return null; + const { spec, magH, magW } = mags; const fontSizeDots = (magH * spec.capInkPerMag) / spec.capPerEm; const scaleX = ((magW * spec.advancePerMag) / (fontSizeDots * spec.advPerEm)) * diff --git a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts index 9402d30d..3d64ce68 100644 --- a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts +++ b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts @@ -1,5 +1,5 @@ import { builtinFontFamily, resolveDeviceFontId, resolvePreviewFontName } from "../customFonts"; -import { applyDeviceFontCase, deviceFontMetrics } from "./deviceFonts"; +import { applyDeviceFontCase, deviceFontInkWidthDots, deviceFontMetrics } from "./deviceFonts"; import { getFontFamily } from "../fontCache"; import type { LabelObject } from "../../types/Group"; import type { LabelConfig } from "../../types/LabelConfig"; @@ -71,6 +71,32 @@ export function computeTextRenderMetrics(input: TextMetricsInput): TextRenderMet return { content, fontFamily, fontScaleX, inkWidthDots, fontSizeDots: input.fontSizeDots }; } +/** Ink extent feeding the FO/I and FO/B anchor shift: cell grid for bitmap + * device fonts, measured PrintLab width otherwise. The single derivation + * shared by emit and parse; one-sided drift breaks byte-exact round-trips + * for rotated fields. */ +export function anchorInkWidthDots(input: { + fontId: string | undefined; + content: string; + fontHeight: number; + fontWidth: number; + printerFontName?: string; +}): number { + const cellGrid = deviceFontInkWidthDots( + input.fontId, + input.fontHeight, + input.fontWidth, + input.content, + ); + if (cellGrid !== null) return cellGrid; + return computeTextRenderMetrics({ + content: input.content, + fontHeight: input.fontHeight, + fontWidth: input.fontWidth, + printerFontName: input.printerFontName, + }).inkWidthDots; +} + /** Canvas-only preview font priority: fontId -> printerFontName -> defaultFontId. * Emit/parse omit `label` so round-trip stays PrintLab-based. */ export function getTextRenderMetrics( diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index 0823a698..91455704 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -17,6 +17,7 @@ import { gfaHeaderDims, headerByteSource, type ImageProps } from "../registry/im import { BARCODE_1D_TYPES, STACKED_2D_TYPES, getEntry } from "../registry"; import { GRAPHIC_ANCHOR_TYPES } from "../registry/zplHelpers"; import type { PageLabel } from "../types/LabelConfig"; +import type { Variable } from "../types/Variable"; import { effectiveDpmm } from "../types/LabelConfig"; import { isAxisSwapped, objectRotation, type ZplRotation } from "../registry/rotation"; import { resolveTextMode } from "../registry/text"; @@ -34,6 +35,9 @@ export interface BoundingBoxDots { export interface ObjectBoundsCtx { label: PageLabel; + /** Lets the emitted-anchor preflight resolve a single-bind marker the way + * emission does; absent, the marker text itself is measured. */ + variables?: readonly Variable[]; /** Measured footprints (dots) published by the render layer for types whose * size isn't purely computable (barcodes, single-line text). Keyed by obj.id. * The FT anchor uses uprightBar*Dots; barHeightDots is the legacy fallback. */ diff --git a/packages/core/src/lib/zplParser/flushField.ts b/packages/core/src/lib/zplParser/flushField.ts index a9389bdc..9a4543fe 100644 --- a/packages/core/src/lib/zplParser/flushField.ts +++ b/packages/core/src/lib/zplParser/flushField.ts @@ -21,7 +21,7 @@ import { dataMatrixFdToGs1Content } from "../dataMatrixFd"; import { zplAnchorToModel } from "../labelGeometry/textPositionTransforms"; import { resolveDeviceFontId } from "../customFonts"; import { blockInterLineExtentDots } from "../zebraTextLayout"; -import { computeTextRenderMetrics } from "../labelGeometry/textRenderMetrics"; +import { anchorInkWidthDots } from "../labelGeometry/textRenderMetrics"; import type { TextProps } from "../../registry/text"; import type { Code128Props } from "../../registry/code128"; import type { Code39Props } from "../../registry/code39"; @@ -242,17 +242,6 @@ export function createFlushField( if (s.field.fieldType !== "text") commitPendingReverseBg(); switch (s.field.fieldType) { case "text": { - // ZPL anchors ^FO at cap-top and ^FT at baseline; our internal - // model stores the Konva render position (EM-top-left) so editor - // interactions stay shift-free. The FO/I and FO/B shifts also - // need the rendered ink width; measure it the same way the - // renderer does so the round-trip stays exact. - const { inkWidthDots } = computeTextRenderMetrics({ - content: decoded, - fontHeight: s.field.textH, - fontWidth: s.field.textW, - printerFontName: s.field.pendingPrinterFontName, - }); // FT pins the last baseline, so the EM-top sits one block-extent above; // FO R/I stack the same way. ^TB is a fixed clip height, ^FB stacks // lines. Must match the generator's blockExtentFor for byte-exact @@ -275,6 +264,16 @@ export function createFlushField( defaultFontId: s.defaults.cfFontId, }, ); + // ZPL anchors ^FO at cap-top and ^FT at baseline; our internal + // model stores the Konva render position (EM-top-left) so editor + // interactions stay shift-free. + const inkWidthDots = anchorInkWidthDots({ + fontId: anchorFontId, + content: decoded, + fontHeight: s.field.textH, + fontWidth: s.field.textW, + printerFontName: s.field.pendingPrinterFontName, + }); const modelPos = zplAnchorToModel( s.field.x, s.field.y, diff --git a/packages/core/src/registry/text.ts b/packages/core/src/registry/text.ts index 2d080390..9525fed1 100644 --- a/packages/core/src/registry/text.ts +++ b/packages/core/src/registry/text.ts @@ -207,7 +207,7 @@ export const text: ObjectTypeCore = { // Serial mode: plain ^A field whose ^FD is wrapped by ^SN/^SF. No block, // reverse, ^FP or variable binding (those are cleared on switch). if (p.serial) { - return `${textFieldPos(obj, ctx?.label)}${resolveFontCmd(p, ctx)}${serialFieldData(p.content, p.serial)}`; + return `${textFieldPos(obj, ctx?.label, ctx?.variables)}${resolveFontCmd(p, ctx)}${serialFieldData(p.content, p.serial)}`; } const mode = resolveTextMode(p); const fontCmd = resolveFontCmd(p, ctx); @@ -233,7 +233,7 @@ export const text: ObjectTypeCore = { const fpDir = p.fpDirection ?? "H"; const fpGap = p.fpCharGap ?? 0; const fpCmd = fpDir !== "H" || fpGap > 0 ? `^FP${fpDir},${fpGap}` : ""; - const anchor = textFieldPos(obj, ctx?.label); + const anchor = textFieldPos(obj, ctx?.label, ctx?.variables); const fd = fdFieldFor(content, ctx, undefined, encodeDefault); // ^FR is the spec-true reverse: it knocks the glyph ink out of whatever is // already drawn (e.g. a black ^GB placed behind the text), so we emit it as diff --git a/packages/core/src/registry/zplHelpers.ts b/packages/core/src/registry/zplHelpers.ts index f154e8c4..601c2fd3 100644 --- a/packages/core/src/registry/zplHelpers.ts +++ b/packages/core/src/registry/zplHelpers.ts @@ -1,13 +1,14 @@ import type { LabelObjectBase } from "../types/LabelObject"; import { effectiveDpmm, type JmDensity } from "../types/LabelConfig"; import type { ZplEmitContext } from "../types/ZplEmit"; +import type { Variable } from "../types/Variable"; import { hasTemplateMarkers, markersToEmbeds } from "../lib/fnTemplate"; import { hasClockMarkers, markersToTokens } from "../lib/fcTemplate"; import { hasControlMarkers, resolveControlMarkers } from "../types/controlKey"; import { classifyField } from "../lib/variableField"; import { modelToZplAnchor } from "../lib/labelGeometry/textPositionTransforms"; import { resolveDeviceFontId, type DeviceFontLabel } from "../lib/customFonts"; -import { getTextRenderMetrics } from "../lib/labelGeometry/textRenderMetrics"; +import { anchorInkWidthDots } from "../lib/labelGeometry/textRenderMetrics"; import { blockInterLineExtentDots } from "../lib/zebraTextLayout"; import type { LabelObject } from "../types/Group"; import { objectRotation } from "./rotation"; @@ -117,6 +118,8 @@ export function wrapReverse(reverse: boolean | undefined, body: string): string interface TextLikeObjForFieldPos extends LabelObjectBase { props: { fontHeight: number; + fontWidth: number; + content: string; rotation: "N" | "R" | "I" | "B"; fontId?: string; printerFontName?: string; @@ -169,26 +172,36 @@ export function resolveFontCmd( /** Numeric ZPL anchor (cap-top/baseline) for a text-like field. `label` * resolves the effective device font (^CF default, ^CW alias override) - * for the anchor snap. */ + * for the anchor snap; `variables` resolve a single-bind marker to the + * default the wire actually prints (^FN{n}^FD{default}). */ export function textZplAnchorCoords( obj: TextLikeObjForFieldPos, label?: DeviceFontLabel, + variables?: readonly Variable[], ): { cmd: "FO" | "FT"; x: number; y: number; } { const cmd = obj.positionType === "FT" ? "FT" : "FO"; - const metrics = getTextRenderMetrics(obj as unknown as LabelObject); const p = obj.props; const blockExtentDots = blockExtentFor(p); const anchorFontId = resolveDeviceFontId(p.fontId, p.printerFontName, label ?? {}); + const cls = variables ? classifyField(p.content, variables) : undefined; + const anchorContent = cls?.kind === "single" ? cls.variable.defaultValue : p.content; + const inkWidthDots = anchorInkWidthDots({ + fontId: anchorFontId, + content: anchorContent, + fontHeight: p.fontHeight, + fontWidth: p.fontWidth, + printerFontName: p.printerFontName, + }); const a = modelToZplAnchor( obj.x, obj.y, { ...p, fontId: anchorFontId }, obj.positionType, - metrics?.inkWidthDots ?? 0, + inkWidthDots, blockExtentDots, p.blockWidth ?? 0, ); @@ -200,8 +213,9 @@ export function textZplAnchorCoords( export function textFieldPos( obj: TextLikeObjForFieldPos, label?: DeviceFontLabel, + variables?: readonly Variable[], ): string { - const a = textZplAnchorCoords(obj, label); + const a = textZplAnchorCoords(obj, label, variables); // Same echo contract as fieldPosZ (text is never import-normalised). const z = obj.fieldJustify === "R" ? ",1" : ""; return `^${a.cmd}${a.x},${a.y}${z}`; diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index d8417efc..530c503e 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -310,6 +310,11 @@ describe("mcp-server tools", () => { expect(nearEdge.warnings.some((w) => w.objectId === "q" && w.kind.startsWith("offLabel"))).toBe(true); }); + it("judges off-label for a bound rotated field by its printed default, not its marker", () => { + const v = ok(validateZpl("^XA^PW800^LL400^FO10,150^AGI,60,40^FN1^FDM5i^FS^XZ")); + expect(v.warnings.filter((w) => w.kind.startsWith("offLabel"))).toEqual([]); + }); + it("validate_zpl reports the intersection rect of two overlapping boxes", () => { const v = ok(validateZpl("^XA^FO0,0^GB100,100,3^FS^FO60,60^GB100,100,3^FS^XZ")); expect(v.overlaps).toHaveLength(1); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 025e5eb7..24b299c6 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -203,9 +203,10 @@ function preflightOf( objects: LabelObject[], label: PageLabel, pageIndex: number, + variables: readonly Variable[], measured?: ObjectBoundsCtx["measured"], ): PreflightWarning[] { - return computePreflight(exportableLeaves(objects), { label, measured }, "mm").map((f) => ({ + return computePreflight(exportableLeaves(objects), { label, measured, variables }, "mm").map((f) => ({ pageIndex, objectId: f.objectId, kind: f.kind, @@ -364,7 +365,7 @@ function boundReport( const probed = measuredBarcodes(pages, label, measured); return { warnings: perPage(pages, label, (objects, pageLabel, i) => - preflightOf(objects, pageLabel, i, probed)), + preflightOf(objects, pageLabel, i, variables, probed)), ...geometryFor(pages, label, probed), }; }); diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index c8648889..57e55181 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -613,7 +613,7 @@ export const LabelCanvas = forwardRef(function LabelCa const isMultiSelection = selectedIds.length > 1; // Snapshot (not the live map) so the frame derivations recompute when a // settled footprint changes; the changing snapshot reference is the signal. - const frameCtx = { label, measured: measuredSnapshot }; + const frameCtx = { label, measured: measuredSnapshot, variables }; // Hidden leaves never render, so the old client-rect path ignored them; keep // them out of the model-based bounds too. // visibleLeaves carries the cascaded (effective) lock; read it, not the raw diff --git a/src/lib/labelGeometry/deviceFonts.test.ts b/src/lib/labelGeometry/deviceFonts.test.ts index 601a6f75..a52e67ec 100644 --- a/src/lib/labelGeometry/deviceFonts.test.ts +++ b/src/lib/labelGeometry/deviceFonts.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { applyDeviceFontCase, deviceFontMetrics } from "@zplab/core/lib/labelGeometry/deviceFonts"; +import { applyDeviceFontCase, deviceFontInkWidthDots, deviceFontMetrics } from "@zplab/core/lib/labelGeometry/deviceFonts"; import { ZPL_BUILTIN_FONT_LETTERS, builtinFontFamily } from "@zplab/core/lib/customFonts"; describe("device-font table parity", () => { @@ -109,3 +109,20 @@ describe("deviceFontMetrics", () => { expect(wide.fontSizeDots).toBe(narrow.fontSizeDots); // height unchanged }); }); + +describe("deviceFontInkWidthDots", () => { + it("uses the cell grid: n*advance - gap/2", () => { + // Font G: advance 48, cell 40 -> gap 8; Labelary-measured extent. + expect(deviceFontInkWidthDots("G", 60, 0, "M5i")).toBe(3 * 48 - 4); + expect(deviceFontInkWidthDots("G", 120, 0, "M")).toBe(2 * (48 - 4)); + }); + it("counts the case-folded visible content", () => { + // H drops lowercase entirely, so "M5i" prints as two glyphs. + expect(deviceFontInkWidthDots("H", 21, 0, "M5i")).toBe(2 * 19 - 3); + expect(deviceFontInkWidthDots("H", 21, 0, "iii")).toBe(0); + }); + it("returns null for scalable ids", () => { + expect(deviceFontInkWidthDots("0", 50, 0, "M")).toBeNull(); + expect(deviceFontInkWidthDots(undefined, 50, 0, "M")).toBeNull(); + }); +}); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index f83acbc1..338dea87 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -158,6 +158,24 @@ describe('parseZPL — ^MU units of measure', () => { expect(commandsOf({ findings }, 'partial')).toContain('^MU'); }); + it('anchors a rotated device-font field by the cell-grid extent', () => { + // I anchors at the field's cell edge: the em-box lands one cell-grid + // extent (3*48 - gap/2 = 140 for ^AG) past ^FO. + const r = parseSingle('^XA^FO300,150^AGI,60,40^FDM5i^FS^XZ', 8); + expect(defined(r.objects[0]).x).toBe(300 + 140); + const out = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, r.objects, r.variables); + expect(out).toContain('^FO300,150'); + }); + + it('keeps the ^FO anchor stable for an ^FN-bound rotated device-font field', () => { + // The wire prints the variable default, so the anchor extent must be + // measured from it, not from the «marker» the content holds in-model. + const r = parseSingle('^XA^FO300,150^AGI,60,40^FN1^FDM5i^FS^XZ', 8); + expect(defined(r.objects[0]).x).toBe(300 + 140); + const out = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, r.objects, r.variables); + expect(out).toContain('^FO300,150'); + }); + it('round-trip: ^MUD,b,c parses + generates back symmetrically', () => { const original = '^XA^MUD,200,600^PW600^LL400^CI28^XZ'; const { labelConfig } = parseSingle(original, 8); diff --git a/src/test/deviceFontBoxMatch.test.ts b/src/test/deviceFontBoxMatch.test.ts index d2595853..221294fa 100644 --- a/src/test/deviceFontBoxMatch.test.ts +++ b/src/test/deviceFontBoxMatch.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { GlobalFonts } from '@napi-rs/canvas'; import { PNG } from 'pngjs'; -import { deviceFontMetrics } from '@zplab/core/lib/labelGeometry/deviceFonts'; +import { deviceFontMetrics, deviceFontInkWidthDots } from '@zplab/core/lib/labelGeometry/deviceFonts'; import { zplAnchorToModel } from '@zplab/core/lib/labelGeometry/textPositionTransforms'; import { builtinFontFamily } from '@zplab/core/lib/customFonts'; import { deviceFontBoxMatchCases } from '../../tests/fixtures/deviceFontBoxMatchCases'; @@ -74,25 +74,33 @@ describe('Device font box-match: substitutes vs. Labelary bitmap fonts', () => { if (!metrics) return; const face = faceFor(tc.fontId); + const inkWidth = + deviceFontInkWidthDots(tc.fontId, tc.fontHeight, tc.fontWidth, tc.text) ?? 0; const model = zplAnchorToModel( tc.x, tc.y, { fontHeight: tc.fontHeight, rotation: tc.rotation, fontId: tc.fontId }, 'FO', + inkWidth, ); - const drawX = model.x + metrics.xOffsetDots; - const drawY = model.y + metrics.yOffsetDots; const { canvas, ctx } = inkCanvas(); + // Konva rotates the node about its position; the device nudges are + // node-local, so they apply inside the rotated frame. + const deg = { N: 0, I: 180, B: 270 }[tc.rotation]; + ctx.save(); + ctx.translate(model.x, model.y); + ctx.rotate((deg * Math.PI) / 180); drawKonvaText(ctx, { text: tc.text, - x: drawX, - y: drawY, + x: metrics.xOffsetDots, + y: metrics.yOffsetDots, fontSizePx: metrics.fontSizeDots, fontFamily: face.family, scaleX: metrics.scaleX, letterSpacingPx: metrics.letterSpacingDots, }); + ctx.restore(); const localPng = PNG.sync.read(canvas.toBuffer('image/png')); const labelaryPng = PNG.sync.read( diff --git a/tests/fixtures/deviceFontBoxMatchCases.ts b/tests/fixtures/deviceFontBoxMatchCases.ts index de0fece7..adc05895 100644 --- a/tests/fixtures/deviceFontBoxMatchCases.ts +++ b/tests/fixtures/deviceFontBoxMatchCases.ts @@ -11,7 +11,7 @@ export interface DeviceFontBoxMatchCase { /** Width parameter of `^A{id},h,w`. 0 = derive from height. */ fontWidth: number; text: string; - rotation: 'N'; + rotation: 'N' | 'I' | 'B'; x: number; y: number; } @@ -89,8 +89,29 @@ const sweepCases: DeviceFontBoxMatchCase[] = FONT_PLANS.flatMap((plan) => })), ); +/** Rotated fields anchor at the cell edge and extend along the reading + * direction, so their position exercises the deterministic cell-grid + * extent (deviceFontInkWidthDots) end to end. */ +const rotatedCases: DeviceFontBoxMatchCase[] = ['A', 'E', 'G'].flatMap((fontId) => { + const plan = FONT_PLANS.find((f) => f.fontId === fontId); + if (!plan) throw new Error(`no plan for ${fontId}`); + return (['I', 'B'] as const).flatMap((rotation) => + [1, 2].map((mag) => ({ + id: `f${fontId}_rot${rotation}_m${mag}`, + fontId, + fontHeight: plan.magStep * mag, + fontWidth: 0, + text: 'M5i', + rotation, + x: 300, + y: 150, + })), + ); +}); + export const deviceFontBoxMatchCases: DeviceFontBoxMatchCase[] = [ ...magCases, ...snapCases, ...sweepCases, + ...rotatedCases, ]; diff --git a/tests/fixtures/labelary_devicefont_images/fA_rotB_m1.png b/tests/fixtures/labelary_devicefont_images/fA_rotB_m1.png new file mode 100644 index 0000000000000000000000000000000000000000..1eb750b8b0122d64ff70fb297ccb6020ffc6f1e7 GIT binary patch literal 6570 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+TSG1w36GLn`JZ|FGwBJE)<+WnkFT6llQJ!L)d0 zsz?u$(!q>0N!G;;9tN`_LGnU9Y12T`ETht+!7`d0MzaBJN{7}IgQsdOD7f@`&6=*RQ+#cK21bmB{|7qQSls*j|Ns97{}(5uFsZcx1uk$UCnRi0 zmS9yDHuxjYx;Q~ZfOUq18{@?Xa?FQ+Eje|a2dK2^;u$BVj-(s+UMasgZNTNGCUI6r zrO`IlF|}sBgxf(-@z%=nVb!xI^=xhTdw@DznA-j?Zphe>_Vd&0|Mk!R$pejt6@@-L zKpBNqYnYfoMne=DXfAp3<$s_-60rPK8f_GfHi}032BTdjP*-xaZ!p?780{O3W`ohb0SSErCI+Lb XB{vw|a}EQiR2V#6{an^LB{Ts5rO2Su literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fA_rotB_m2.png b/tests/fixtures/labelary_devicefont_images/fA_rotB_m2.png new file mode 100644 index 0000000000000000000000000000000000000000..94176c4d341e21e880ebc615fd883babbfba5227 GIT binary patch literal 6709 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+T1 zRFNJgrGpu1lB|mxJPc+1jAjGcmJW)-d#nx^aJflHoYhHb zs_o-hoY2yE@r)6d8{@L*XP$$|wl5=DAUfMrz&dwt!Xw2JcQ z>@0thBv_Ss4gScpE@`NH`CnkeoQMB4BoZ4sfl7h;MS%L3Fh1sK3p5B~Jp4b4ey|HTPQ z7}eSW9ZXKlJ8|H^293{)vzLAU{%0rD*-lz35^wI#zBTP~OzD3~w}U5ug>0)!!T-V@ zWoG7P$7h>o0~MwK{pY0g<4D302Bu#;>m{JdL_WMf&hPmwo=4%s)W&w8({&Dfs2367 zJtFC#@Xns&iLuR3UrNF{jrD45&h^asW&<+EL9Zr!hm%jZ0nXdr~tOj#qnHfinzWx0R6j1`%*z{a@ zt;Es&|K(Yg1%MR@)1(9Y|MT$70s2OTsqO#bhKdh0UAmKf9(Dp9;L-&2AJd2QNhd7- zya$H!LSQ&gU^p_f^sC?lXs}OU*wQlb;Qv5_LkFO>&w(SrL;$LuMysFET6wfJGTI6o zZJdra%11jRqn)tPPS|KCY_t0m~hBQvgquGGArGp~#o~pwJTy6>yXLVE> zRw~*kPL_z9b+qM)(Fy|tgB1onO%qup{`E5nHGKO2|Nm)gAn`T+Cy+e#-~FHmlMm10 z2A2nuD(8S;KTokpJUqs4HhvR{vYVT^x+TCp4j5zqo)5| z)&9Sq4k|%~K<;c523mfC6oK- z;Ez1(k>HqA^wGaOGn{}9tetc|B6yeDS7L6xZ7x9!!PB>uC zstYuC53tZw6p#4u-tq_o%YTUj4-TwmUoPQxa0A=onXNGguKqf2nOT%yBf~7QX49sK zlLlOErV?j$QkviVob>tgDe>AwhQ|G`4;>Esn=Zk6Sh6QgY|=qt$(dqo0v;75?++QX zaoqTCX7c5wA<(KhAge?}zD}8P!`Pu=k)1p<%bHEoCv1xYnQ9C)bxG5XrY5FnHj@wO zwJgleKVH@IEKZPUzIet->%{q_hK9q64gyCR*uw2tj15}$>^cV2W|#o13_O@$efqDM z&}$)~^5_5mcu$)*_5wdTv;WGo9u^0>DCppmf50$KX}WmEh|7&}Vnb)52(aRC5CK}b zI6*{!)6Mv_h6Jk%Og_P6Ge}xxRC+X6fXQVvIgI9#(b8cw8;n*Kqm|@nZ7|v>8f_Gf zHi}032BTdjP*-xaZ!p?780{O3W`ohb0lvNg!%I=ALXo%o_kbpgJYD@<);T3K0RVDg B^1%QA literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fE_rotB_m1.png b/tests/fixtures/labelary_devicefont_images/fE_rotB_m1.png new file mode 100644 index 0000000000000000000000000000000000000000..55e524033ba1c99614d574214cea613a7a297ae7 GIT binary patch literal 6892 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+T1 zRFNJgrGpu1lB|mxJPc+1jAnzuTskPS+H9Y4!RqXR4+lco z8A}fD1D0)qj1vF)nI0YFVPk7M^Z);A$$$fG{}(5?FsQW!I@tU;ec-@{_Qr?o%*_1u zJRA`>jS)Ib>)59C{{KJS*g)nVu&|U#NRnVZ!hCl6dHWwAbGh7%xjh?Vn`S49^n?IQ z%M%PaJUlr(ixZACUOeN()^Y8#af$@1GM~X8dDg`VA_A;4B!C5~1gi^B+K9`IabkkW zW?<3lpdxWr2PDtwW_%hX-Goz`+cRO&RYx`six~?}sXsILt$$cU!m#1te+>o34aOG# z*&FRWc?1%^Jo&F7p$Rm60*i#in*YrXYRt^d3l`b{$wRk+K5qg0+{(hTxv~E4?P7jD z4+EPMAeXX$T&lvr#`gNZgTUzn8~)gH*odSgSbVP+>EStW#DL3#eYb?lVF?F^b)Aig zY(UE;LAEf?+n=%_A&H?`rL1i((EBM3An#WkKA>=rfiWhGhd&-1rohnZe2`a|{J)5e zt&C0MLUO{6vj$vlED6Ah$b+GWCt$)ad9GO!65IaCvnn$IYb7Qj2L@)54nv1V4d%n) zZGi?#42S;*I&f8#ef@DRwD^}=?O%S@?@vSGfv#3&f>l#KKo=wShKOQ-J9T=|G&R! zB0JD~nF*5S%p3-{zux%+OfC|@XjN%i|615Z;(){6FOU8Q8cYH@N>TQYZOEEa3`}dv zf%*hlK<2M(=$szkz{v9t6i_Unbs%=bt0lHPOA zIrn$Y@7#OOU9Zc{^p#JQV;JU}ot6F;h7pnICxd@rjW=pj;3u?d-omQ4E2~$QmSH)? zC1uNsSJu+nC~Xuoi-{?Ti`N>;ADsDQ6iU8WXSmh0oQZK*^B8$O-r8KI zbl|VKE6OQxE0@Kzyobj99p&55ut*pdU=h+mputO19RmE_!P1bB>));>6`cujcw&9z z0XHMJQ+ilBvw)UzxrTo=E82}N-}tDpFmcZ$M|r?G&X*8f8JaBOYbX!b%eU%lEN4%o zPSqJX?mBN7Ac!QzkXcDayLuUUpM8+ z4^Hx#U?#m0kn0ZfQtKc|pBXVL9VD8c1x`L8S-RV?wpi(ihXp%}NR@`}Og7&?5Y(eq z)pFN(H67G##4I#HLV4*UTCooLl%c< zo5hmiJ<8`X8tB?C(MWsDABT`-#q*8f1=%=k`JJ=c9uq`D*Oe*G>Ami@fY`@zaP=MI ztM|z%(d%`?o-pyU*m92e{=AXTbbBC+ZRvCmCTbje-7*MBHEX&7R~iZTzG+h!j_o&# zYIgN=es{+-wEnt3HC! zX^^Q{r#D$S*I0((%}x9ieFUKj9!i$mgY+dTv68bLrZ{JT1Ax7|Db~5MSV_)-VNv5B zQyk0K0hM#J6tnJ$hT&ugxK@7;A4@2@OffX!E|k#TLoINI7R>-ZBr}+0CJ~WUhFKPq zt@`aKU43d?7%m~(*$sZ1$m z`39;O&v?I@Rs8)*XzK`u7m^ih9S|)?@Q5eiAx-n!n)pMuG+gu%x{is2D5Qwf8K$x^nA{W}}htv#&869q2`Zctd z-2{*dfQV2uo(jGrhgO&#om(36&ZY0h{4yeT&nIKyKO5%n3K#%m^j?p zW+i*Eu{xBv@Q`LRoH@+~--b2F9*B}5+!{#s0^}L0A-i5xY$~1z*Ir9UV(38gs*J5L z+R-kLS=k^vM$z2aFYk;%V6CX+Q2QC0D|~WlC)6$jNV{pvdO&$>QEQ$;vWp-nS<;~M zln(4$e0mS8A{W=f{P63#QgW=1lgA$0G%^YuiGx@ftZmx4mnfa3;_@hzQz5w|Z+AjL zJrLI?@lYxnN+~VLPbYn@YH*>_=tY3CK)tC?z5qa;}byLgrjR%<=PE#5lp#yNx{Jt{=>j z{sBoMq0v`2|2Y7Ke2@@%S*2i^_mBPzomj^~M>T3AhktS8)59>_TL?7CxdY%;JrB7) z`)XtlvB;o1#VU2f-sk1Jf_N~?FoE?}3q!0JASNi$K;myw_28Zxu>qhJ<(qo53tAKn z)fag<9Z_%maQ%YRP~qS&TzG_wo^bya?)^f)L+JAeeIB9DBlLM*nm&)v-WIz4!fS`{ e%JaW|`~sL=omup7cO`sq#IWpnx#=CLdh1_w2NPTX literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fE_rotI_m1.png b/tests/fixtures/labelary_devicefont_images/fE_rotI_m1.png new file mode 100644 index 0000000000000000000000000000000000000000..cee3f103ac206758e686caa41c5d001b3de176c0 GIT binary patch literal 6947 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+T1 zRFNJgrGpu1lB|mxJPc+1jAnxYDIHp6KIA@TXUlo;bz|=8 zcq4}c=NIpnSn%Ne#(bW|2``x2{x5DQ5jd6bS)O^0@8om_rmO!29vl|GYkI6b8V6Q@gHQ$3<)-uq4p5RSl{luIJh>N zhlgJeSZY6H1o|q}L5SJ;P**+Aiv(e20kCRjpz2Qt?kl_Z&G~hff%#h}kGyhf(c;dA@|i4~Yp4 z_RP)tppa~M_#f!T3?qXLH9z}V_$;EE8(EkSZw312_<;il{(k-c|9uS)4^Pm4_k$PM zfl(o&aPGi?4}a{1Hl#E(r~g+>*t~e_-A(@;Hr%>*EAcDH*T8yEkv+n|;KTnP{VZx5 zj2#$~9{|N;z~VNu4Zq$3@-7{K)|Mc*fveNe>TZ8eTIrbjywqn)SGPUC2& zakSGo+G!k+PUC1pbhIHl+7KOWh>kWyM;oG}4bef@5M^+_zd-!)t*Kd{*)LC5KbLh* G2~7a!Sb%&0 literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fE_rotI_m2.png b/tests/fixtures/labelary_devicefont_images/fE_rotI_m2.png new file mode 100644 index 0000000000000000000000000000000000000000..3208ea7bede153f5c23189acff6ea231735588db GIT binary patch literal 7491 zcmeHMYfuwc6u!HgTw>g4s(=Y%vtnx_RooB+)Y>d6AQm*V>d>m=0)}WsMGF%9FuOqz zH8!^53$0@5v^ZKCA06q8)mQi$ zq#KVDmlWxh_1p})_RY}v5O>jz(6B-tmSK^%gG__}b9OkOT`+S~$5|$+x7=PhJF9qW zxxZ5)8~nNU$L~P0d+F-t301lD3~*M;Wd<}|5e1i$G-`C>Y4fQK3IQ*m?F&_!mq zON%;OLA?0zzNB*81NITY4Ap{ab2xv~~nYtb`v5Fl40;D@X+7Tf~%6kZSs?e-m%? zZp75d2*aOsS~DzbOs`wTEgjsTF1u;Lx27@(0T8R6Rn0x zfz4P%+tZyG3>grWx}XQ2$)pn6?g`U`!#kxT{Not%F7u?_Ij8P2P;^Ps2ATt-2yIhP zGW;vt1>Fau@vFxh6Il!oX!Jhe*Ccq@e{B|m_^2JYD)#F##5#0DSJwKMM-j=?s*{^4 zv8V;Cpzd)N0o-YURPWcJu*V~(jl@aP=pRGO6FT0Fudc-OB_>w6N5=>^JeVTvsuH68an;Ohiwv&v% z;{w2!1trTRfT!Oi8LHnINe&WFfQ^K^52u9|hU~on6n$iyFd9;9xt;ZDZNQa7*0VH^ z=u_Uk5mWW*yqx_G;@<8JJb!Tyrs`T?M`2RYeFkMIj3t?^4-HXE%!1e=G2R{6pwA?a zOT>2RGO!QC`v#H`TDgJr(?q~ep8RaTSyjOsQ&!zNGZ@|yGojXBC@8aE)N|Y~8xg3< zVNmZE&mB8XV_Rd=YF;&Qz(6GUTLCasQ?5k)wDA)aLaV zqV6UN!gh)6UXQ`W;bnxeug37k{_m9?V`vteAFOv-q0uT0R^BG9{=N_*;V-t6+LE9p z!PmlRVxM}{jVhYM9)2!aORBeEWU2=_TS1`u4R3edA=@||5$`*7bx7Fy zjN+#jvxk?J^&dWgl2UiXv-%GkiU33Pp%E=mt`Ft9SiUaEG?1@{@~v4u4dlNhxJ(0? y1~Ls~8pwA8`JcJmN|0M9a_dO$sQuSYA_i&}7M)+-cI#@WJDQa~YkK2U*7Y}?AXRq& literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_rotB_m1.png b/tests/fixtures/labelary_devicefont_images/fG_rotB_m1.png new file mode 100644 index 0000000000000000000000000000000000000000..76fede75294bcadc148d5c7e7af0543cfd1d7723 GIT binary patch literal 7255 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 Fe*l847VZE5 literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_rotI_m1.png b/tests/fixtures/labelary_devicefont_images/fG_rotI_m1.png new file mode 100644 index 0000000000000000000000000000000000000000..95bec2469d36705f46b39a49cd3407ad7c2adc57 GIT binary patch literal 7437 zcmeHM`%@EF6u!HgEFoSnQVI!1Sqq~KBC`Y^_~;NK2GN3{(i+E6H#~w;Du}44)oc<) z7&D4>d_YlAoR;x{IE>0zADbcq6%jkF<%d>+Grn3>rs_!Lk)EYYf9oI6&dwy+B>^rIix!xRmga9P%*;j~Su(Qo zEQMQ`*yz}3b{ZR#F(WQE%le}8iy`owDtgW@%(fJ*&0oi)ul+n5rR1-RVq#`72^(`5 z9jlwk#Kg^EXTpm~N=d(#Jg*8N?S|yUg!JO7%>H<#(H+4k*%+52ja8PCH5J)`F7gBS z8mmTL!!Kkjmy7lQm-UcnmPuww7AYO1GWZY84nbF(wwA3Y&m9cknVtToD&V)E?*6;1 zvV`Vf@@pk~a4|n%tMY8R&Qg({x6Av6<3qR{WPj_uKy0B^jb2+|=XjVN$vKX(6_xLG z>#805Rs;lUjBXXGsn~VQ)A+&_kIh1UNg68?)>u!3b@jg9X$izDVeV{|)jd-Pw-EVE z;km>6Y4MO7$qjY;XIG4ynCrqd+*4HIt>H6-yNLAq^1%UEC9EiV)NNDr7uBa8u-oke z|Hj7tFc@^9)^V@hFr~mjU;}ix zTn-&h_0W`}z|6WFlg1ch#~c+hX_bk~0kohIv?Z8f+CkoE^_{39@`gsw?;aK7sjN$* z_k0Lpu8f4~i{*v!bfNwXR-Mwh90V<*>-Q3}iB~fjDymp1yL}zn?ITh3dkER^1df?d zb*%}>x-UWezYRhafgkJ2{XdQ2-H|W`^&{A3MGP-oI)KBSKVh!^c`O7qi2}&X293Zx zokP0n^&dWgyqZmGK;W#$UssPBYS9<}H5g}#nxi>Wn%o*V!hhxMvkg3z~l8Ymto^w|PU zxL;!udb~Fs-H;i>p;SE#`+(QmpjNAotRD0s{6NLsUa$8yv=2csJmAGK$>__UMo~^@ z9HkHmXt#C%0Sw!a1V8_)AdMmiISW8~+h=N{o44O<*VTeTGTb$gu=k#`p+X>FsVAA3$_`fb~@JFEC|9l z0m*aeQNi?L#8UV1H}Lz|Rm_ec{z1SU3?R}mU9S$=?UNjY|92Xmd;g#25uoQy$Y;@H zxbX~7M*w7yv)f4pt0JFFsdgsgV)rKKNPILc69+eL;7gDL!{DZ)gS!|(uh2#%`ZR;r zSm=HM9j>~CWbuF`@Ia}NxZy(ulwao5a>~aTd)q_XdvPn{0thWYIL9Zl)x60Fb;3cN z;9TZmJ5Oev9o5hfUp}On+t|Vr4|bIBNm00#dzlka4#oYN#@-~NnLiFyxX)qnKcY4g z&7PiZDhSl1Wd}p$HC&)d4$bZewLDQ&M`cwmq?Y_WBG()yYW=R$)9i%dl zW(R3jlvV?2k(4$E(xyn-D@uE0>1-e!nWUqlbVQb(9Q@Com>@K%qhxy4<~I1#5~1V; Ksfl$4&hZy)NBW!q literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_rotI_m2.png b/tests/fixtures/labelary_devicefont_images/fG_rotI_m2.png new file mode 100644 index 0000000000000000000000000000000000000000..4936cfc837ac9c967430dafd90f5458aecff401e GIT binary patch literal 8141 zcmeHM`&Sd!7M{sTh7cwaV#Em*7)7tqi#SPC5LAY0LA!herLOo`HGu%Or9!RN2vlYw zW~nz;URAu{GG1Es)>m4eRqLIAXsD>zd#$$B5_^4cv1-(=mLethoT2Sc{STTSva)g> zd++c2_SyTKl})C(SxQBW0>d!nteG?BV;D$8A2}SrYVWSifM0Q|UNWy*P`r9&UOqP4 znwwu_UAdMvCK;2Mag089Vv5mL@TjBw7r64TXyx>k`PNmh6|bOkUVAein^U|Zk=7^E z8LJCv17ny(>r+yhNvTXSoi!(i8#4rYi#N`ik&$C>%8pctLeIKV>w=H=HXR!wH&5;z z6@7gC&9}E&H62o>FJ3lXQ@&{XM_1Z8rDwVH#EQ~jg~p_H6-2+zO(kvXok1JFZ2zsV zv;lDI`-h#u!Ne3&UJ^`yBJeo=V=z97+ho;{Qv+w`CatQG7 znM?g;r{_P5AaaH4*VesGHgStB6ONFRJc63 zGuc>f-1CnecH$r2UsWuJ6m3j~kI+O}IVO`~^9 zHcU-x*Op*ycXn<~t-P{{vvr=5$OxxMpizrZN=$sraMzGko9%{{$) zhLmEp!T!7?$j|GDpD5;bGBD`6EjC(We)MfC+;`7NVhkO+_un!a+K>XI$g3*LhfWI~*{wr(<9oJ6GZ z3x3k7EJIp0hKY6;0%D|x=jU360JXW5`3^~}7V7ZuVV_-MhXyJp0`7QLw^t_OG>g_y6npBSx+cA9nAiZlj(q7@; z(Z&&%Q%}eG>(wXTH6{)F47qxi=alU{c_ozveQbsy}n5!$@>Qr~C)eYO zg?g6_1+A*OG5kC`8lf)wP)kwGkepk^&L|Qgmvm=ov|W;QhYLAJ2|;__&*iu^WmG1E z@<_2SZlcW{cH`N5xWw$xa@?3o(X0iTjjo!qt%VIkW{0&>EDiBY>dU1*MR@drer;_c zIOsjBgdASRh=re!s(^KLiw|Y85AST)a+5qNJ3WnIu_Y=%F-`;Pec4$Z0YmBljjPwZ zFp63pzEIEd4K$Ee8d>k2crqaxC9(Y6gSbQT`&L1cQm%#Uq$i8)XzIrpSmFLdaaD9F zpB35R4I`f7OL{gZr*(g}fvUo4GtPf;y@7&z(x&NIY;QUsE1_U7JENf6h|jpQBT~Ea z-EzG`*%!C%&F+2)4f0U{h*5@#&WNaCV%Ti3|L8%=k5zFGZC2O0H4~~ z)85<4# zvV1et88xsPbRK7r>gtYbHV&GkGSSIy!jwfne~#gjIMN4QAc;*-i%dMm+=|KgA~*_V zc&FS(?SL}4Bsqcn7l5YH=*H$uCFDJyR$FZjcxDtP@4d&QitcXb6wUA)z@q|hRU5Wh z?!Q~^uD3D{2vpVH3@2esKb1M7!XZK9_&UinT43bfy|GACj>vIQwEudkI1K^f4eJ<| ztBVDxa+pl-lL_L(g_A%TGjxODXdXlYGcOkO*&LpDmnvD$h-rIKCt$ZpJBy_aWTVCn?wN=wtgyxUfk;mW zzjGv*rn=0`7g_9rT#OI=*FxSu`qH zJ#4@_N;U)DZ48P`yc&hz$Yw_(lyDv<*+n!h#vPgt(Gp6B&?yO3hfq}vHH%P_3|$UF tcc#!?F?2@`Jsm<%rqEOIPxnN|ERlw;V1B_$_^&%Q>!rCf4!vk`{2yai8#Mp` literal 0 HcmV?d00001