web: give each watermark its own collapse, and a face to print in

The panel shared one column between the two marks, so GPS's colour, its two
switches and its hand-typed place stood open beside the custom mark's text,
colour and size whether or not either mark was on. The two are now collapses,
one per mark: the header chip is the section, and that mark's own controls sit
under it. What opens a section is the mark itself — GPS WATERMARK ON opens
GPS's controls, CUSTOM WATERMARK ON opens the custom mark's — so there is no
new state and no way for a panel to disagree with the pixels.

Both marks gain the FONT strip the phone has had (TEXT FONT for the custom
mark, FONT for GPS, whose stamp the phone also lets you set a face on). A
browser has no font service, so the list is exactly what the bundle carries:
the site's two self-hosted families, Inter and Fraunces (SIL OFL), their latin,
latin-ext and vietnamese woff2 subsets decompressed, pinned to weight 400 @
opsz 14 and merged into ONE TTF per family — drawText has no glyph fallback, so
a family mapped to only the latin subset would print a Vietnamese place name as
tofu. DEFAULT stays the bundled Cousine face, which is what every existing
session and every mark without a family prints.

Two engine bugs came out of it. CanvasKit 0.42's Font.getGlyphWidths passes its
output pointer where the wasm export wants the bounds pointer, so every glyph in
a run comes back holding one identical, rounded width — at 64px on the merged
Inter face, 'H' and 'i' both answered 42, while hmtx says 0.743em and 0.242em,
and a box measured off it was 27% too wide ("Hà Nội 09/23" 510px against a true
403px). The shim now rebinds it with the pointers in the order
_getGlyphWidthBounds reads them, and the stage's boxes measure with linear
metrics, which land on hmtx exactly (403.28px against 403.28; hinted is 407).
And CanvasKit's TypefaceFontProvider.matchFamilyStyle answers null for every
style shape this binding accepts, so a name registered with it never resolved —
the shim keeps its own registry keyed by family name instead.

Measured: tsc clean; the engine harness on the merged faces 26/26, including the
registry's advances against hmtx (Inter 6.3013em, Fraunces 6.3475em); the
deployed app under Playwright 38/38 over the two collapses and both FONT strips
— each mark's controls appear only with its own mark on, the DEFAULT/INTER/
FRAUNCES box widths match hmtx, the baked ink fills the box, the top edge
re-hangs off the new ascent (Inter 0.96875em against Cousine's 0.8325em, 3.4px
at this size) with the left edge fixed, and UNDO round-trips. Opening a section
narrows the stage by 168px with no window resize (955px -> 787px), so the stage
now re-measures its drop boxes off a ResizeObserver on the frame and the
picture rather than on the next render.

Not ported: the phone's GPS watermark still prints in the bundled face only
(no emulator here to verify a phone-side font strip), and the FONT options are
not behind the PRO gate the way the phone gates non-default families.
This commit is contained in:
2026-09-23 10:59:49 +07:00
parent 438788e031
commit d55b7b49ca
9 changed files with 286 additions and 113 deletions
+53 -11
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
import { samplePixel } from '../engine/imageOps';
import { measureMark } from '../engine/exportEngine';
import { Histogram } from './Histogram';
// One wheel notch, and how far the photo may be pulled open.
@@ -11,13 +12,14 @@ const ZOOM_MAX = 8;
const ZOOM_SETTLE_MS = 160;
// The watermarks' own face — the engine draws both marks with the bundled
// Cousine face (the custom mark's FONT option is never set on web), and
// Cousine face unless the panel picked a family from the FONT strip, and
// renderPhoto's numbers are what the boxes below read back: the face is 3.2% of
// the stamp width, its advance is 0.6001em and its ascent/descent
// 0.8325/0.3003em. The custom mark's baseline sits 0.35em under y; GPS draws
// its rows 1.2em apart from a baseline 1.1em under y, with an icon column 1.6em
// wide before the text. A box built from those same constants hugs exactly the
// text the file will carry.
// text the file will carry — and a mark carrying a named family is measured
// instead (measureMark), because those constants are Cousine's alone.
const WM_FONT_FRAC = 0.032;
const WM_ADVANCE_EM = 0.60009765625;
const WM_ASCENT_EM = 0.83251953125;
@@ -43,11 +45,33 @@ export type WmMark = {
// The icon column GPS prints before its text, in em (0 for the custom mark).
gapEm: number;
// The rows as they will be drawn; the face is monospace, so the longest line
// in characters is the width and the count is the height.
// in characters is the width and the count is the height — unless a family
// from the FONT strip is set, when the face itself is measured.
lines: string[];
// The family the panel picked for this mark, null for the bundled face.
font: string | null;
onMove: (p: { x?: number; y?: number; size?: number }) => void;
};
// One mark's box, from the numbers the engine draws it with. The width of the
// text is the face's own when the mark carries a family (measureMark), and the
// monospace em above otherwise; the ascent/descent come back with it, because
// the box's top is a baseline's height under y and another family's ascent is
// not Cousine's. Keyed by the mark itself so the drag handlers can reuse it.
const wmBoxOf = (mark: WmMark, fontPx: number) => {
const measured = mark.font ? measureMark(mark.font, fontPx, mark.lines) : null;
const ascent = measured?.ascent ?? WM_ASCENT_EM * fontPx;
const descent = measured?.descent ?? WM_DESCENT_EM * fontPx;
const text = measured?.width ?? Math.max(0, ...mark.lines.map((l) => l.length)) * WM_ADVANCE_EM * fontPx;
return {
ascent,
// In em, so the drag maths can move y by the height a growing face adds.
ascentEm: fontPx > 0 ? ascent / fontPx : WM_ASCENT_EM,
width: Math.max(fontPx, mark.gapEm * fontPx + text),
height: ascent + descent + (mark.lines.length - 1) * WM_ROW_STEP_EM * fontPx,
};
};
// The centre column: drop target, preview canvas, and the PEEK comparison.
// Dragging a photo anywhere on the page works, not just on the frame — the
// whole stage is the drop zone.
@@ -218,7 +242,21 @@ export function ImageStage({
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
// Opening a chip adds an options column and narrows the stage, with no
// window resize to hear about it — so the boxes stayed measured against the
// wider photo until some later render happened to re-run this effect. Watch
// the two things they are measured off instead.
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
if (ro) {
const wrap = wrapRef.current;
const img = imgRef.current;
if (wrap) ro.observe(wrap);
if (img) ro.observe(img);
}
return () => {
ro?.disconnect();
window.removeEventListener('resize', measure);
};
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]);
const grabPanel = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -509,14 +547,14 @@ export function ImageStage({
if (!box) return [];
return marks.map((mark) => {
const fontPx = wmFontPx(box.width, mark.size);
const longest = Math.max(0, ...mark.lines.map((l) => l.length));
const { ascent, width, height } = wmBoxOf(mark, fontPx);
return {
mark,
fontPx,
left: mark.x * box.width,
top: mark.y * box.height + (mark.baselineEm - WM_ASCENT_EM) * fontPx,
width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx),
height: (WM_ASCENT_EM + WM_DESCENT_EM + (mark.lines.length - 1) * WM_ROW_STEP_EM) * fontPx,
top: mark.y * box.height + mark.baselineEm * fontPx - ascent,
width,
height,
};
});
}, [marks, box]);
@@ -534,6 +572,9 @@ export function ImageStage({
left: number;
width: number;
fontPx: number;
// The face's ascent in em, so a resize keeps the box's top where it is
// whichever family the mark draws in.
ascentEm: number;
} | null>(null);
const beginWmDrag = (mark: WmMark, mode: 'move' | 'size') => (e: React.PointerEvent<HTMLElement>) => {
@@ -544,7 +585,7 @@ export function ImageStage({
if (!b || !(b.width > 0 && b.height > 0)) return;
e.currentTarget.setPointerCapture(e.pointerId);
const fontPx = wmFontPx(b.width, mark.size);
const longest = Math.max(0, ...mark.lines.map((l) => l.length));
const { width, ascentEm } = wmBoxOf(mark, fontPx);
wmDragRef.current = {
mode,
mark,
@@ -554,8 +595,9 @@ export function ImageStage({
px: e.clientX,
py: e.clientY,
left: b.left + mark.x * b.width,
width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx),
width,
fontPx,
ascentEm,
};
};
@@ -582,7 +624,7 @@ export function ImageStage({
const fontPx = wmFontPx(b.width, size);
d.mark.onMove({
size,
y: cl(d.y + ((WM_ASCENT_EM - d.mark.baselineEm) * (fontPx - d.fontPx)) / b.height, 0, 1),
y: cl(d.y + ((d.ascentEm - d.mark.baselineEm) * (fontPx - d.fontPx)) / b.height, 0, 1),
});
};