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
+118 -77
View File
@@ -11,7 +11,7 @@ import { ClearPhotoModal } from './ui/ClearPhotoModal';
import { ExportModal } from './ui/ExportModal';
import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
import { useI18n } from './i18n/I18nProvider';
import { loadEngine } from './engine/assets';
import { loadEngine, MARK_FACES } from './engine/assets';
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
@@ -151,8 +151,8 @@ const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
// The option strips, one open at a time (phone's GroupKey set minus the groups
// the web engine has no knob for).
type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm'
| 'wmColor' | 'wmSize' | 'gpsColor' | 'gpsName' | 'gpsTime';
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'gpsColor', 'gpsName', 'gpsTime'];
| 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont';
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont'];
const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
// A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP).
const PARAM_GROUP: Partial<Record<string, GroupKey>> = { temperature: 'temp', straighten: 'rotate' };
@@ -178,6 +178,13 @@ const WM_SIZES = [
{ v: '1.5', d: '1.5x' },
{ v: '2', d: '2x' },
];
// FONT's list: the faces the bundle carries (see engine/assets.ts). The phone
// lists the device's own families through the platform font manager; a browser
// has no font service, so the honest list is what we ship.
const WM_FONTS = [
{ v: 'DEFAULT', d: 'DEFAULT' },
...MARK_FACES.map((f) => ({ v: f.family, d: f.family.toUpperCase() })),
];
interface LoadedSource {
bytes: Uint8Array;
@@ -190,13 +197,13 @@ interface LoadedSource {
// multiplier of the base face — 3.2% of the stamp width. The drag box on the
// photo reads and writes exactly these three numbers, so what the box encloses
// is what the file gets.
type MarkStyle = { color: string; size: number; x: number; y: number };
const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92 };
type MarkStyle = { color: string; size: number; x: number; y: number; font: string | null };
const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92, font: null };
// GPS carries the same three geometry numbers plus its two switches, and its
// box on the photo reads and writes them the same way the custom mark's does.
type GpsStyle = { color: string; showName: boolean; showTime: boolean; size: number; x: number; y: number };
const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7 };
type GpsStyle = { color: string; showName: boolean; showTime: boolean; size: number; x: number; y: number; font: string | null };
const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7, font: null };
// Where each mark's first row sits under y, and the icon column GPS prints
// before its text — the engine's own em values, mirrored from the box that
@@ -1859,6 +1866,24 @@ export function Workspace() {
setMarkStyle((prev) => ({ ...prev, size: Number(v) }));
},
},
// The two FONT chips: the custom mark's own face, and GPS's (the phone
// gives GPS a FONT strip too — it used to print in the bundled face only).
wmFont: {
label: 'TEXT FONT', off: 'DEFAULT', value: markStyle.font ?? 'DEFAULT',
options: WM_FONTS,
onPick: (v) => {
remember();
setMarkStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v }));
},
},
gpsFont: {
label: 'FONT', off: 'DEFAULT', value: gpsStyle.font ?? 'DEFAULT',
options: WM_FONTS,
onPick: (v) => {
remember();
setGpsStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v }));
},
},
gpsColor: {
label: 'COLOR', off: '#f59e0b', value: gpsStyle.color,
options: WM_COLORS,
@@ -1919,7 +1944,12 @@ export function Workspace() {
// no GPS is no longer a dead end — the switch stays live wherever the browser
// can be asked for the device's own position (see locateMe).
const canLocate = typeof navigator !== 'undefined' && !!navigator.geolocation;
const wmChips = (): ChipDef[] => [
// Each watermark type is a collapse of its own: its chip is the header, and
// the header's panel (col 2) carries that mark's own controls under it — its
// colour, its face, its switches, its fields. Before, the two marks shared one
// column, so both sets of controls sat open at once. The mark being OFF is
// what closes the section, the same rule the phone's panel draws.
const gpsChips = (): ChipDef[] => [
{
key: 'gps',
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
@@ -1935,7 +1965,9 @@ export function Workspace() {
setUseGeotag(!useGeotag);
},
},
...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []),
...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsFont'), groupChip('gpsName'), groupChip('gpsTime')] : []),
];
const customChips = (): ChipDef[] => [
{
key: 'custom-wm',
label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
@@ -1945,7 +1977,7 @@ export function Workspace() {
setMarkOn(!markOn);
},
},
...(markOn ? [groupChip('wmColor'), groupChip('wmSize')] : []),
...(markOn ? [groupChip('wmColor'), groupChip('wmSize'), groupChip('wmFont')] : []),
];
// Every tab carries RESET, pinned outside the scrolling chips.
@@ -1962,6 +1994,7 @@ export function Workspace() {
|| markOn || markText !== ''
|| markStyle.color !== DEFAULT_MARK_STYLE.color || markStyle.size !== DEFAULT_MARK_STYLE.size
|| markStyle.x !== DEFAULT_MARK_STYLE.x || markStyle.y !== DEFAULT_MARK_STYLE.y
|| markStyle.font !== DEFAULT_MARK_STYLE.font
|| useGeotag
|| (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]);
}, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]);
@@ -2421,78 +2454,84 @@ export function Workspace() {
</div>
</div>
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */}
{/* column 2 — FRAME's WATERMARK chip opens the two collapses, one per
watermark type: the header chip, then that mark's own controls */}
{wmOpen ? (
<div className="col col-sub strip wm-extra" data-col="wm">
<ChipRow
chips={[
{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) },
...wmChips(),
]}
/>
{markOn ? (
<input
className="field"
value={markText}
placeholder="TYPE WATERMARK TEXT"
maxLength={80}
onChange={(e) => setMarkText(e.target.value)}
/>
) : null}
{useGeotag ? (
<>
<ChipRow chips={[{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }]} />
{/* GPS WATERMARK — its colour, its face, its two rows, its place */}
<div className="wm-sec" data-key="wm-sec-gps">
<ChipRow chips={gpsChips()} />
{useGeotag ? (
<>
<input
className="field"
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
/>
<p className="hint">
{gps
? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual')
: t('wm.noExif')}
</p>
{!gps ? (
<>
<div className="chip-row">
<button
type="button"
className="chip"
data-key="gps-locate"
disabled={locating}
onClick={() => locateMe()}
>
{locating ? t('wm.locating') : t('wm.useLocation')}
</button>
</div>
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lat: e.target.value }))}
onBlur={() => {
const lat = Number(gpsManual.lat);
const lng = Number(gpsManual.lng);
if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) {
setGps({ latitude: lat, longitude: lng, locality: place, timestamp: capturedAt ?? Date.now() });
setUseGeotag(true);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
</>
) : null}
</>
) : null}
</div>
{/* CUSTOM WATERMARK — its text, its colour, its size, its face */}
<div className="wm-sec" data-key="wm-sec-custom">
<ChipRow chips={customChips()} />
{markOn ? (
<input
className="field"
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
value={markText}
placeholder="TYPE WATERMARK TEXT"
maxLength={80}
onChange={(e) => setMarkText(e.target.value)}
/>
<p className="hint">
{gps
? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual')
: t('wm.noExif')}
</p>
{!gps ? (
<>
<div className="chip-row">
<button
type="button"
className="chip"
data-key="gps-locate"
disabled={locating}
onClick={() => locateMe()}
>
{locating ? t('wm.locating') : t('wm.useLocation')}
</button>
</div>
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lat: e.target.value }))}
onBlur={() => {
const lat = Number(gpsManual.lat);
const lng = Number(gpsManual.lng);
if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) {
setGps({ latitude: lat, longitude: lng, locality: place, timestamp: capturedAt ?? Date.now() });
setUseGeotag(true);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
</>
) : null}
</>
) : null}
) : null}
</div>
</div>
) : null}
@@ -2623,6 +2662,7 @@ export function Workspace() {
baselineEm: CUSTOM_BASELINE_EM,
gapEm: 0,
lines: [markText],
font: markStyle.font,
onMove: setWatermarkPos,
},
]
@@ -2637,6 +2677,7 @@ export function Workspace() {
baselineEm: GPS_BASELINE_EM,
gapEm: GPS_GAP_EM,
lines: gpsLines,
font: gpsStyle.font,
onMove: setGpsPos,
},
]
+18 -5
View File
@@ -1,11 +1,23 @@
import { initSkia, hasGpu } from './skiaShim';
import type { RenderAssets } from './exportEngine';
// One CanvasKit instance per page, one copy of the three render assets. The wasm
// file is served from /wasm/ (scripts/copy-wasm.mjs puts it there at build time);
// the fonts and the wall-frame artwork are in public/assets.
// One CanvasKit instance per page, one copy of the render assets. The wasm file
// is served from /wasm/ (scripts/copy-wasm.mjs puts it there at build time); the
// fonts and the wall-frame artwork are in public/assets.
let assets: RenderAssets | null = null;
// The faces the watermarks' FONT option offers beside DEFAULT. There is no OS
// font service in a browser, so the list is exactly what the bundle carries —
// and each family is ONE file, because drawText has no glyph fallback: a name
// mapped to only the latin subset would print a Vietnamese place name as tofu.
// The two here are the site's own self-hosted faces (styles/fonts.css, SIL OFL),
// with their latin / latin-ext / vietnamese woff2 subsets decompressed, pinned
// to weight 400 @ opsz 14 and merged into one TTF per family (fontTools).
export const MARK_FACES: { family: string; file: string }[] = [
{ family: 'Inter', file: '/assets/Inter-Regular.ttf' },
{ family: 'Fraunces', file: '/assets/Fraunces-Regular.ttf' },
];
async function buf(url: string): Promise<ArrayBuffer> {
const res = await fetch(url);
if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`);
@@ -22,12 +34,13 @@ export function probeGpu(): boolean {
export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> {
await initSkia({ locateFile: (file) => `/wasm/${file}` });
if (!assets) {
const [textFont, emojiFont, wallframePng] = await Promise.all([
const [textFont, emojiFont, wallframePng, markFonts] = await Promise.all([
buf('/assets/Cousine-Regular.ttf'),
buf('/assets/NotoEmoji-GPS.ttf'),
buf('/assets/wallframe.png'),
Promise.all(MARK_FACES.map(async (face) => ({ family: face.family, bytes: await buf(face.file) }))),
]);
assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng) };
assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng), markFonts };
}
return { assets, gpu: probeGpu() };
}
+52 -5
View File
@@ -51,6 +51,8 @@ export interface RenderAssets {
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
wallframe: Uint8Array; // wallframe.png
// The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES).
markFonts: { family: string; bytes: ArrayBuffer }[];
}
export interface RenderOptions {
@@ -63,7 +65,7 @@ export interface RenderOptions {
watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
watermarkRotation?: number;
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null;
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean; font?: string | null } | null;
wallframeLandscape?: boolean;
frameWindowZoom?: { s: number; u: number; v: number } | null;
photoRotation?: 0 | 90 | 180 | 270;
@@ -235,16 +237,61 @@ function loadFaces(assets: RenderAssets) {
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont);
if (!typeface || !iconTypeface) return null;
faces = { typeface, iconTypeface };
// Named-family lookup for the custom watermark's FONT option: register the
// bundled face so at least that one name resolves on web.
// Named-family lookup for the watermarks' FONT option: register the bundled
// face under the name it is known by, then the faces the FONT strip offers.
try {
Skia.FontMgr.System().registerFace(typeface, 'Cousine');
for (const face of assets.markFonts) {
const named = Skia.Typeface.MakeFreeTypeFaceFromData(face.bytes);
if (named) Skia.FontMgr.System().registerFace(named, face.family);
}
} catch {
/* provider optional */
}
return faces;
}
// The face a mark carrying a named family draws with, or null for the bundled
// one. The stage's drag boxes ask the same question, so they measure with the
// same face the file will print.
function namedFace(font: string | null | undefined) {
return font ? Skia.FontMgr.System().matchFamilyStyle(font) : null;
}
// The face's own numbers, for the boxes the stage draws over a mark: the width
// of the widest line plus the face's ascent/descent, all in pixels at `px`.
// Returns null while the engine is still loading, when no family was picked, or
// when the name is not one the bundle ships — the caller then falls back to the
// monospace em its constants know (ImageStage's WM_ADVANCE_EM and friends).
export function measureMark(
font: string | null | undefined,
px: number,
lines: string[]
): { width: number; ascent: number; descent: number } | null {
if (!Skia || !font || !lines.length) return null;
const face = namedFace(font);
if (!face) return null;
const sized = Skia.Font(face, px);
// Unhinted advances: hinted ones come back rounded per glyph, and a dozen of
// them drift a few pixels off the line they are supposed to bound.
sized.setLinearMetrics?.(true);
try {
const metrics = sized.getMetrics();
let width = 0;
for (const line of lines) {
const advances = sized.getGlyphWidths(line) as unknown as ArrayLike<number>;
let sum = 0;
for (let i = 0; i < advances.length; i++) sum += advances[i];
width = Math.max(width, sum);
}
return { width, ascent: -metrics.ascent, descent: metrics.descent };
} catch {
return null;
} finally {
sized.delete?.();
}
}
// Both walls are one artwork on one code path: `wallframe` hangs the PNG turned
// 90° CW (the portrait opening), `wallframe-landscape` lays it down as it is.
const isWallframe = (id: FrameId) => id === 'wallframe' || id === 'wallframe-landscape';
@@ -691,7 +738,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const gpsPos = options?.gpsWatermark ?? null;
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
const gpsFont = own(Skia.Font(typeface, gpsSize));
const gpsFont = own(Skia.Font(namedFace(gpsPos?.font) ?? typeface, gpsSize));
const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
const gpsGap = Math.round(gpsSize * 1.6);
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
@@ -717,7 +764,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
for (const mark of [customWm, liteWm]) {
if (!mark) continue;
const wmSize = Math.round(fontSize * (mark.size ?? 1));
const wmFace = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null;
const wmFace = namedFace(mark.font);
const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
const wmPaint = own(Skia.Paint());
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
+4 -3
View File
@@ -27,13 +27,14 @@ export interface StudioState {
place?: string;
useGeotag?: boolean;
// size/x/y are the GPS box on the photo; older sessions hold only the colour
// and the two switches and take the defaults for the rest.
gpsStyle?: { color: string; showName: boolean; showTime: boolean; size?: number; x?: number; y?: number };
// and the two switches and take the defaults for the rest. font is the family
// the FONT strip picked, null for the bundled face.
gpsStyle?: { color: string; showName: boolean; showTime: boolean; size?: number; x?: number; y?: number; font?: string | null };
markText?: string;
markOn?: boolean;
// x/y are fractions of the stamp the mark is drawn on (the drag box writes
// them straight back); older sessions hold no x/y and take the default.
markStyle?: { color: string; size: number; x?: number; y?: number };
markStyle?: { color: string; size: number; x?: number; y?: number; font?: string | null };
}
export function loadState(): StudioState {
+36 -12
View File
@@ -49,6 +49,30 @@ function patchPrototypes(canvasKit: any): void {
proto.dispose = function dispose() { this.delete(); };
}
}
// CanvasKit 0.42's `Font.getGlyphWidths` is wrong: it 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. Probed at 64px on the
// merged Inter face, 'H' and 'i' both answered 42, while hmtx says 0.743em and
// 0.242em (47.6px and 15.5px) — and any box measured off it was 27% too wide
// ("Hà Nội 09/23" 510px against a true 403px). Rebind it with the pointers in
// the order `_getGlyphWidthBounds` actually reads them (verified against
// fontTools hmtx on both bundled faces).
canvasKit.Font.prototype.getGlyphWidths = function (glyphIDs: any, paint: any) {
const ids = typeof glyphIDs === 'string' ? this.getGlyphIDs(glyphIDs) : glyphIDs;
if (!ids || !ids.length) return new Float32Array(0);
const idPtr = canvasKit._malloc(2 * ids.length);
const outPtr = canvasKit._malloc(4 * ids.length);
try {
canvasKit.HEAPU16.set(ids, idPtr >> 1);
this._getGlyphWidthBounds(idPtr, ids.length, outPtr, 0, paint ?? null);
return Float32Array.from(
canvasKit.HEAPF32.subarray(outPtr >> 2, (outPtr >> 2) + ids.length)
);
} finally {
canvasKit._free(idPtr);
canvasKit._free(outPtr);
}
};
// frameUtils draws strokes with the numeric RN style (0 = fill, 1 = stroke).
const setStyle = canvasKit.Paint.prototype.setStyle;
canvasKit.Paint.prototype.setStyle = function (style: any) {
@@ -190,24 +214,24 @@ export async function initSkia(options: InitSkiaOptions = {}): Promise<any> {
return Skia;
}
// Named-family lookup for the custom watermark's FONT option. Only faces the
// bundle ships can be matched on web; anything else falls back to the caller's
// default typeface — the honest behaviour when there is no OS font service.
// Named-family lookup for the watermarks' FONT option. Only faces the bundle
// ships can be matched on web; anything else falls back to the caller's own
// typeface — the honest behaviour when there is no OS font service.
//
// The registry is ours, not CanvasKit's TypefaceFontProvider: that one answers
// `null` for every style shape this wasm binding accepts (probed with
// `{weight:400,width:5,slant:0}`, with the enum objects, and with `.value`
// unwrapped — all null), so a name registered with it never resolves.
let fontMgr: any = null;
function makeFontMgr(canvasKit: any) {
const provider = canvasKit.TypefaceFontProvider.Make();
function makeFontMgr(_canvasKit: any) {
const faces: Record<string, any> = Object.create(null);
return {
registerFace(typeface: any, familyName: string) {
provider.registerFont(typeface, familyName);
if (typeface && familyName) faces[familyName.trim().toLowerCase()] = typeface;
},
matchFamilyStyle(family: string) {
if (!family) return null;
try {
return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null;
} catch {
return null;
}
return (family && faces[family.trim().toLowerCase()]) || null;
},
};
}
+5
View File
@@ -109,6 +109,11 @@
("GPS WATERMARK OFF", "CUSTOM WATERMARK OFF" + PRO), which wrap in the
default column width. */
.wm-extra { border-right-color: var(--accent-line); width: 200px; }
/* The watermark panel is two collapses, one per mark: each header chip stands
on its own controls (colour, face, switches, fields), and a rule keeps the
two apart while both marks are on. */
.wm-sec { display: flex; flex-direction: column; gap: 6px; }
.wm-sec + .wm-sec { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.chip {
border: 1px solid var(--border);
background: var(--bg-elev);
+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),
});
};