@ingcreators/annot-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,157 @@
1
1
  # @ingcreators/annot-core
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2e8d397: Switch the client-capture PNG-8 default quantizer to the pure-TS
8
+ Median Cut at `@ingcreators/annot-core/encode/quantize-median-cut`.
9
+ `DEFAULT_ENCODE_OPTIONS.quantizer` is now `"median-cut"`. The
10
+ `@ingcreators/annot-imagequant` WASM import and the `ensureWasm()`
11
+ initialisation path are removed from `@ingcreators/annot-core/encode`.
12
+ The `quantizer: "wasm"` value on `EncodeOptions` is retained for
13
+ back-compat (now resolves to the same Median Cut implementation)
14
+ so consumers that persisted `quantizer: "wasm"` to localStorage
15
+ keep working unchanged.
16
+
17
+ Phase 2 of `docs/plans/replace-libimagequant-with-median-cut.md`.
18
+ Phase 3 retires the annotator / MCP dynamic-import path; Phase 4
19
+ deletes the workspace package and deprecates the published
20
+ `@ingcreators/annot-imagequant@0.1.0` on npm.
21
+
22
+ - df1a429: **`@ingcreators/annot-annotator` — new `Annotator.toEditablePng()`
23
+ method** that returns a re-editable PNG. The bytes carry the same
24
+ visible pixels as `toPng()` plus the original un-annotated capture +
25
+ the annotations SVG embedded in the PNG's XMP / custom `svGo` chunk.
26
+ Re-opening the file in the Annot editor (or `annot.work/app/`)
27
+ restores the annotations as selectable / movable / restylable
28
+ objects rather than a flat bitmap.
29
+
30
+ ```ts
31
+ const annotator = createAnnotator();
32
+ const editablePng = annotator.toEditablePng({
33
+ originalDataUrl,
34
+ annotationsSvg,
35
+ width,
36
+ height,
37
+ tags: {
38
+ source: "playwright-fixture",
39
+ capturedAt: new Date().toISOString(),
40
+ },
41
+ });
42
+ await writeFile("shot.png", editablePng);
43
+ ```
44
+
45
+ Image viewers that don't know about the custom chunks display the
46
+ rasterised pixels verbatim — no compatibility loss vs `toPng()`.
47
+
48
+ The existing `toPng()` / `toSvg()` / `toEncoded()` methods are
49
+ unchanged — `toEditablePng()` is purely additive.
50
+
51
+ **`@ingcreators/annot-core` — new `/xmp-bytes` Tier-A subpath**
52
+ exposing the pure-bytes XMP encode / decode primitives that used to
53
+ live (Blob-wrapped) inside `/xmp`:
54
+ - `createEditablePngBytes(opts) -> Uint8Array` — write a re-editable
55
+ PNG. Takes raw PNG bytes for both the rasterised image and the
56
+ original capture; no `Blob` / `FileReader` dependency. The
57
+ function the new `Annotator.toEditablePng()` is built on.
58
+ - `readEditablePngBytes(data) -> AnnotMetadata | null` — PNG-only
59
+ reader.
60
+ - `readEditableImage(data) -> AnnotMetadata | null` — dual PNG /
61
+ JPEG reader (moved here from `/xmp`, also re-exported from `/xmp`
62
+ for source-compat).
63
+ - `WELL_KNOWN_TAG_KEYS` — soft-convention key names for the
64
+ optional `tags` field (`source` / `screen` / `capturedAt` /
65
+ `commit`).
66
+
67
+ Existing `@ingcreators/annot-core/xmp` consumers stay working
68
+ without source changes — `xmp-browser.ts` re-exports the Tier-A
69
+ surface alongside its Blob-wrapped `createEditableImage`.
70
+
71
+ - 5e74421: Add `quantizeMedianCut` — a pure-TS Median Cut + Floyd–Steinberg
72
+ dither quantizer at `@ingcreators/annot-core/encode/quantize-median-cut`
73
+ — and an opt-in `quantizer?: "wasm" | "median-cut"` field on
74
+ `EncodeOptions`. Default stays `"wasm"` (libimagequant via the
75
+ existing GPL-3.0 WASM dependency) so production behaviour is
76
+ unchanged. Phase 1 of
77
+ `docs/plans/replace-libimagequant-with-median-cut.md`; Phase 2
78
+ will flip the default to `"median-cut"` and Phase 4 will retire
79
+ the `"wasm"` branch entirely.
80
+ - 4768855: **`@ingcreators/annot-product-docs-astro` — new `/playwright`
81
+ subpath** that re-exports an extended Playwright `test` fixture
82
+ whose `page.screenshot()` accepts a compositional `annot: { … }`
83
+ option:
84
+
85
+ ```ts
86
+ import { test } from "@ingcreators/annot-product-docs-astro/playwright";
87
+
88
+ test("login flow", async ({ page }) => {
89
+ await page.goto("/login");
90
+ await page.screenshot({
91
+ path: "public/login.png",
92
+ annot: {
93
+ mdx: { id: "login", path: "src/content/docs/login.mdx" },
94
+ tags: { source: "docs-tour", capturedAt: new Date().toISOString() },
95
+ },
96
+ });
97
+ });
98
+ ```
99
+
100
+ The `annot` option is compositional — each field is an
101
+ independent contribution to the embedded XMP record:
102
+ - `mdx: { id, path }` — refresh the MDX's `annot:snapshot` block
103
+ against the current page, then resolve `<Overlay match>` blocks
104
+ for the named `<Screen>`.
105
+ - `overlays: BboxAnnotation[]` — caller-supplied annotations
106
+ (same DSL `@ingcreators/annot-annotator` accepts). Merged with
107
+ MDX-derived overlays when both are present.
108
+ - `tags: Record<string, string>` — provenance metadata written
109
+ verbatim into the XMP. No auto-fill — callers write the
110
+ `WELL_KNOWN_TAG_KEYS` they want.
111
+ - `editable: boolean` (default `true`) — toggle between
112
+ "annotations preserved as SVG layer + embedded original"
113
+ (re-editable in Annot Cloud) and "annotations baked into
114
+ visible pixels" (flat PNG, no XMP layer).
115
+
116
+ `page.screenshot()` calls WITHOUT `annot` fall through to
117
+ vanilla Playwright byte-for-byte — codegen / DevTools Recorder
118
+ output keeps working unedited.
119
+
120
+ Phase 1 of
121
+ `docs/plans/playwright-screenshot-annot-fixture.md`. Phase 2 will
122
+ add the same interception on `locator.screenshot()` with
123
+ coordinate rebasing for sub-region overlays.
124
+
125
+ Two helpers also exported from the main `@ingcreators/annot-product-docs-astro`
126
+ entry for callers who want to compose annotations themselves:
127
+ - `resolveMdxAnnotations({ mdxPath, screenId, dims })` — extract
128
+ the MDX's `<Overlay>` blocks into a typed `BboxNumberedBadgeAnnotation[]`
129
+ (the underlying step the fixture uses internally).
130
+ - `svgFromBboxAnnotations(annotations)` — wrap a
131
+ `BboxAnnotation[]` into a single-root `<svg>` ready for
132
+ `Annotator.toEditablePng()` / `toPng()`.
133
+
134
+ **`@ingcreators/annot-core` — new `writePngWithTagsOnly`
135
+ helper** exported from `/xmp-bytes` (and re-exported from
136
+ `/xmp`). Writes `tags` into a PNG's XMP iTXt chunk without
137
+ embedding an original capture or annotations layer — for the
138
+ "PNG with provenance metadata sidecar" path (CI failure
139
+ screenshots, VRT references, etc.). The resulting bytes are
140
+ still a valid PNG and the Annot editor treats them as a normal
141
+ PNG (no `<annot:annotations>` element → not editable round-trip;
142
+ opens as fresh canvas).
143
+
144
+ ### Patch Changes
145
+
146
+ - 780985d: Remove the `@ingcreators/annot-imagequant` workspace dependency
147
+ graph entirely. The package's GPL-3.0 WASM was replaced by the
148
+ pure-TS Median Cut quantizer in Phases 1–3 of
149
+ `docs/plans/_done/replace-libimagequant-with-median-cut.md`;
150
+ Phase 4 deletes the workspace package itself + the `verify-wasm`
151
+ CI job + the Cargo dependabot watch + the issue-template option +
152
+ the published 0.1.0 on npm (via the `npm deprecate` operator
153
+ action documented in the Phase 4 PR description).
154
+
3
155
  ## 0.1.0
4
156
 
5
157
  ### Minor Changes
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # @ingcreators/annot-core
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@ingcreators/annot-core.svg)](https://www.npmjs.com/package/@ingcreators/annot-core)
4
+ [![license](https://img.shields.io/npm/l/@ingcreators/annot-core.svg)](https://github.com/ingcreators/annot/blob/main/LICENSE)
5
+
3
6
  The shared core of [Annot](../../README.md). Two tiers live here:
4
7
 
5
8
  - **Tier A** — pure Node, DOM-free. Storage types, SVG format
@@ -16,19 +19,40 @@ PropertyPanel, tools) live in [`@ingcreators/annot-editor`](../editor).
16
19
  Data-driven canvas rendering (gallery thumbnails, the shared OOXML
17
20
  DrawingML builder) lives in [`@ingcreators/annot-render`](../render).
18
21
 
22
+ ## Install (npm)
23
+
24
+ ```sh
25
+ npm install @ingcreators/annot-core
26
+ # or
27
+ pnpm add @ingcreators/annot-core
28
+ ```
29
+
30
+ Most consumers will install the higher-level
31
+ [`@ingcreators/annot-annotator`](https://www.npmjs.com/package/@ingcreators/annot-annotator)
32
+ or [`@ingcreators/annot-playwright`](https://www.npmjs.com/package/@ingcreators/annot-playwright)
33
+ instead — those packages inline the parts of `annot-core` they
34
+ need. Install `annot-core` directly only when building a custom
35
+ headless tool against the SDK.
36
+
19
37
  ## Public entry points
20
38
 
21
- | Subpath | Tier | Surface |
22
- |---------|------|---------|
23
- | `@ingcreators/annot-core` | A | Re-exports everything from `/headless` — DOM-free root entry |
24
- | `@ingcreators/annot-core/headless` | A | Same as root, kept as an alias for callers that want to be explicit |
25
- | `@ingcreators/annot-core/storage` | A | `ImageRecord`, `FolderRecord`, `PageElement`, `PageMetadata`, `StorageProvider` |
26
- | `@ingcreators/annot-core/utils` | A | `assertNonNull`, `computeDasharray`, `newIdB58`, defaults |
27
- | `@ingcreators/annot-core/zip` | A | ZIP builder used by PPTX export |
28
- | `@ingcreators/annot-core/encode` | A | Image encode helpers |
29
- | `@ingcreators/annot-core/editor` | B | jsdom-friendly element helpers + `TOOL_REGISTRY` / `PROPERTY_CONTROLS` |
30
- | `@ingcreators/annot-core/xmp` | browser | `createEditableImage` / `readEditableImage` round-trip |
31
- | `@ingcreators/annot-core/desktop-bridge` | browser | Electron desktop-host IPC + `isDesktop` detection |
39
+ | Subpath | Tier | Surface | Available on npm? |
40
+ |---------|------|---------|---|
41
+ | `@ingcreators/annot-core` | A | Re-exports everything from `/headless` — DOM-free root entry | ✅ |
42
+ | `@ingcreators/annot-core/headless` | A | Same as root, kept as an alias for callers that want to be explicit | workspace only (v0.1.0) |
43
+ | `@ingcreators/annot-core/storage` | A | `ImageRecord`, `FolderRecord`, `PageElement`, `PageMetadata`, `StorageProvider` | workspace only (v0.1.0) |
44
+ | `@ingcreators/annot-core/utils` | A | `assertNonNull`, `computeDasharray`, `newIdB58`, defaults | workspace only (v0.1.0) |
45
+ | `@ingcreators/annot-core/zip` | A | ZIP builder used by PPTX export | workspace only (v0.1.0) |
46
+ | `@ingcreators/annot-core/encode` | A | Image encode helpers | workspace only (v0.1.0) |
47
+ | `@ingcreators/annot-core/editor` | B | jsdom-friendly element helpers + `TOOL_REGISTRY` / `PROPERTY_CONTROLS` | workspace only (v0.1.0) |
48
+ | `@ingcreators/annot-core/xmp` | browser | `createEditableImage` / `readEditableImage` round-trip | workspace only (v0.1.0) |
49
+ | `@ingcreators/annot-core/desktop-bridge` | browser | Electron desktop-host IPC + `isDesktop` detection | workspace only (v0.1.0) |
50
+
51
+ Subpath imports for npm consumers land in a later minor — the
52
+ v0.1.0 published tarball bundles everything into the root
53
+ `./dist/index.js` entry. Workspace consumers (within this
54
+ monorepo) keep using the full subpath map via the source-side
55
+ exports.
32
56
 
33
57
  The Tier A surface is CI-enforced by
34
58
  [`src/headless.test.ts`](./src/headless.test.ts): it imports every
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Encode option types + defaults — split out of `./index.ts` so callers
3
- * that only need the data shape (e.g. the web app's preferences
4
- * loader) can import this leaf without dragging in the WASM
5
- * imagequant binding that `encodeCapture` requires. Keeps the
6
- * encoder lazy-loadable as a separate chunk.
2
+ * Encode option types + defaults — split out of `./index.ts` so
3
+ * callers that only need the data shape (e.g. the web app's
4
+ * preferences loader) can import this leaf without dragging in
5
+ * the heavier encoder + quantizer module that `encodeCapture`
6
+ * requires. Keeps the encoder lazy-loadable as a separate chunk.
7
7
  */
8
8
  export type EncodeFormat = "smart" | "png" | "jpeg";
9
9
  /**
@@ -23,6 +23,23 @@ export declare const SAVE_SIZE_MAX_WIDTH: Record<SaveSizePreset, number | null>;
23
23
  * + the (future) extension settings UI. Shared so both surfaces
24
24
  * speak the same language. */
25
25
  export declare const SAVE_SIZE_LABEL: Record<SaveSizePreset, string>;
26
+ /**
27
+ * Quantizer backend for PNG-8 smart-mode encoding.
28
+ *
29
+ * Both values currently resolve to the in-tree pure-TS Median Cut
30
+ * + Floyd–Steinberg dither at
31
+ * [`quantize-median-cut.ts`](./quantize-median-cut.ts). The
32
+ * `"wasm"` value is retained for back-compat with the Phase 1
33
+ * feature flag so consumers that persisted `quantizer: "wasm"` to
34
+ * localStorage keep working unchanged.
35
+ *
36
+ * Phase 2 of
37
+ * [`docs/plans/_done/replace-libimagequant-with-median-cut.md`](../../../../docs/plans/_done/replace-libimagequant-with-median-cut.md)
38
+ * flipped the default to Median Cut and removed the WASM init
39
+ * path. Phase 4 deletes the `@ingcreators/annot-imagequant`
40
+ * workspace package and removes this field entirely.
41
+ */
42
+ export type Quantizer = "wasm" | "median-cut";
26
43
  export interface EncodeOptions {
27
44
  /** "smart" | "png" | "jpeg" */
28
45
  format: EncodeFormat;
@@ -53,6 +70,12 @@ export interface EncodeOptions {
53
70
  * `Settings.quality.saveSizePreset` field here.
54
71
  */
55
72
  saveSizePreset?: SaveSizePreset;
73
+ /**
74
+ * Quantizer backend used by smart-mode PNG-8 output. Optional;
75
+ * both values resolve to the same Median Cut implementation
76
+ * post-Phase 2. See {@link Quantizer}.
77
+ */
78
+ quantizer?: Quantizer;
56
79
  }
57
80
  export declare const DEFAULT_ENCODE_OPTIONS: EncodeOptions;
58
81
  /** Compute the resize target for a given source size + preset.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure-TS palette quantizer (Median Cut + Floyd–Steinberg dither).
3
+ *
4
+ * Drop-in replacement for the WASM `quantize_image` from the
5
+ * (GPL-3.0) `@ingcreators/annot-imagequant` package. Lives at
6
+ * Tier A — no DOM, no canvas, no WASM. Importable from Node and
7
+ * the browser identically.
8
+ *
9
+ * The API mirrors the existing internal contract so callers can
10
+ * swap one for the other without touching anything else:
11
+ *
12
+ * ```ts
13
+ * const { palette, indices } = quantizeMedianCut(rgba, w, h, 256);
14
+ * ```
15
+ *
16
+ * - `palette`: flat RGBA8 bytes, length `numColors * 4`.
17
+ * - `indices`: one byte per pixel, length `w * h`.
18
+ *
19
+ * Deterministic: same input always produces the same output.
20
+ * Priority-queue ties are broken by insertion order; splits use
21
+ * a stable sort. Useful for snapshot tests.
22
+ *
23
+ * Algorithm summary:
24
+ *
25
+ * 1. Build a histogram (Map<rgba32, count>) over the input.
26
+ * 2. Split samples below an alpha threshold into a dedicated
27
+ * "transparent" palette entry (index 0).
28
+ * 3. Place the remaining samples into a single bounding box and
29
+ * repeatedly split the highest-priority box along its longest
30
+ * RGB edge at the population-weighted median, until the
31
+ * target palette size is reached.
32
+ * 4. Each box contributes one palette entry — the population-
33
+ * weighted mean of its samples.
34
+ * 5. Remap the source image to palette indices with
35
+ * Floyd–Steinberg error diffusion in scanline order.
36
+ *
37
+ * @see `quantize-median-cut.test.ts` for the property tests.
38
+ */
39
+ export interface QuantizeResult {
40
+ /** Flat RGBA8 palette bytes, length = numColors * 4 (1 ≤ N ≤ 256). */
41
+ palette: Uint8Array;
42
+ /** One byte per pixel, indexing into the palette. */
43
+ indices: Uint8Array;
44
+ }
45
+ /**
46
+ * Quantize an RGBA image to ≤ `maxColors` palette entries.
47
+ *
48
+ * @param rgba Packed RGBA8 input, length must equal `width*height*4`.
49
+ * @param width Image width in pixels (≥ 1).
50
+ * @param height Image height in pixels (≥ 1).
51
+ * @param maxColors Target palette size, clamped to [1, 256].
52
+ */
53
+ export declare function quantizeMedianCut(rgba: Uint8Array | Uint8ClampedArray, width: number, height: number, maxColors: number): QuantizeResult;
package/dist/index.js CHANGED
@@ -502,14 +502,12 @@ function de(e, t, n, r, i, a, o) {
502
502
  };
503
503
  n.x += -s * 2, n.y += -c * 2, e.moveTo(d.x - l - u / t, d.y - u + l / t), e.lineTo(d.x, d.y), e.lineTo(d.x + u / t - l, d.y - u - l / t);
504
504
  }
505
- function fe(e, t, n, r, i, a, o) {
506
- let s = t ? .9862 : Math.SQRT1_2, c = r * o * s, l = i * o * s, u = r * (a + o), d = i * (a + o), f = {
507
- x: n.x - c,
508
- y: n.y - l
505
+ function fe(e, t, n, r, i, a) {
506
+ let o = n * a * Math.SQRT1_2, s = r * a * Math.SQRT1_2, c = n * (i + a), l = r * (i + a), u = {
507
+ x: t.x - o,
508
+ y: t.y - s
509
509
  };
510
- n.x += -u - c, n.y += -d - l;
511
- let p = t ? 3.4 : 2;
512
- e.moveTo(f.x, f.y), e.lineTo(f.x - u / 2 - d / p, f.y + u / p - d / 2), e.lineTo(f.x - u, f.y - d), e.lineTo(f.x - u / 2 + d / p, f.y - d / 2 - u / p), e.close();
510
+ t.x += -c - o, t.y += -l - s, e.moveTo(u.x, u.y), e.lineTo(u.x - c / 2 - l / 2, u.y + c / 2 - l / 2), e.lineTo(u.x - c, u.y - l), e.lineTo(u.x - c / 2 + l / 2, u.y - l / 2 - c / 2), e.close();
513
511
  }
514
512
  function pe(e, t, n, r, i) {
515
513
  let a = i / 2, o = {
@@ -542,7 +540,7 @@ function ve(e, t, n, r, i, a) {
542
540
  case "triangle": return ue(e, s, !1, n, r, i, o, a), { filled: !0 };
543
541
  case "stealth": return ue(e, s, !0, n, r, i, o, a), { filled: !0 };
544
542
  case "arrow": return de(e, s, n, r, i, o, a), { filled: !1 };
545
- case "diamond": return fe(e, !1, n, r, i, o, a), { filled: !0 };
543
+ case "diamond": return fe(e, n, r, i, o, a), { filled: !0 };
546
544
  case "oval": return pe(e, n, r, i, o), { filled: !0 };
547
545
  }
548
546
  return { filled: !1 };
@@ -1,10 +1,20 @@
1
1
  /**
2
2
  * Browser-side XMP metadata embedding for re-editable images.
3
- * Mirrors the Rust implementation in src-tauri/src/commands/xmp.rs
4
3
  *
5
- * JPEG: XMP in APP1 segment, original image in multiple APP2 segments
6
- * PNG: XMP in iTXt chunk, original image in custom "svGo" chunk
4
+ * Wraps the Tier-A `xmp-bytes` primitives with Blob input / Blob output
5
+ * and adds the JPEG output path, which needs an Image + <canvas> pipeline
6
+ * to transcode the rendered PNG to JPEG.
7
+ *
8
+ * For Node / non-browser callers, import the Tier-A primitives
9
+ * directly:
10
+ *
11
+ * import { createEditablePngBytes, readEditableImage }
12
+ * from "@ingcreators/annot-core/xmp-bytes";
13
+ *
14
+ * Re-exports of Tier-A symbols below keep existing
15
+ * `@ingcreators/annot-core/xmp` consumers working unchanged.
7
16
  */
17
+ export { type AnnotMetadata, type CreateEditablePngBytesOptions, createEditablePngBytes, readEditableImage, readEditablePngBytes, WELL_KNOWN_TAG_KEYS, writePngWithTagsOnly, } from './xmp-bytes.js';
8
18
  export interface EditableImageOptions {
9
19
  /** Rendered image (screenshot + annotations) as Blob */
10
20
  renderedBlob: Blob;
@@ -25,15 +35,3 @@ export interface EditableImageOptions {
25
35
  * Returns a Blob ready for download.
26
36
  */
27
37
  export declare function createEditableImage(opts: EditableImageOptions): Promise<Blob>;
28
- export interface AnnotMetadata {
29
- originalImageDataUrl: string;
30
- annotationsSvg: string;
31
- width: number;
32
- height: number;
33
- tags: Record<string, string>;
34
- }
35
- /**
36
- * Read XMP metadata from a re-editable image file (JPEG or PNG).
37
- * Pass the file as an ArrayBuffer or Uint8Array.
38
- */
39
- export declare function readEditableImage(data: Uint8Array): AnnotMetadata | null;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Tier-A XMP metadata embedding for re-editable images — pure-bytes
3
+ * primitives that run in Node and the browser alike.
4
+ *
5
+ * This file holds the byte-level XMP build / read / write logic that
6
+ * used to live inside `xmp-browser.ts`. The browser-side
7
+ * `createEditableImage` (Blob in → Blob out) is now a thin wrapper
8
+ * around `createEditablePngBytes` for the PNG path.
9
+ *
10
+ * JPEG WRITE remains browser-only because the rendered PNG → JPEG
11
+ * conversion needs an Image + <canvas> pipeline. The JPEG READ path
12
+ * is pure bytes and lives here.
13
+ *
14
+ * PNG: XMP in iTXt chunk, original image in a custom "svGo" chunk.
15
+ * JPEG: XMP in APP1 segment, original image in one or more APP2
16
+ * segments (each prefixed with `annot:OriginalImage\0`).
17
+ */
18
+ export declare function buildXmp(annotationsSvg: string, width: number, height: number, tags?: Record<string, string>): string;
19
+ export declare function dataUrlToUint8Array(dataUrl: string): Uint8Array;
20
+ export declare function writeJpegWithMetadata(jpegData: Uint8Array, xmpBytes: Uint8Array, originalData: Uint8Array): Uint8Array;
21
+ export declare function writePngWithMetadata(pngData: Uint8Array, xmpBytes: Uint8Array, originalData: Uint8Array): Uint8Array;
22
+ /**
23
+ * Build a tags-only XMP packet — `<annot:tags>` element present,
24
+ * `<annot:annotations>` / `<annot:width>` / `<annot:height>` absent.
25
+ *
26
+ * Used for the "PNG with provenance metadata sidecar" path: image viewers
27
+ * see a normal PNG, the Annot editor reads no `<annot:annotations>` and
28
+ * treats it as an ordinary file, but XMP-aware tools (or this library's
29
+ * own reader) can extract the `tags` for downstream consumption.
30
+ */
31
+ export declare function buildXmpTagsOnly(tags: Record<string, string>): string;
32
+ /**
33
+ * Write `tags` into a PNG's XMP iTXt chunk without embedding an original
34
+ * capture or annotations layer. The resulting bytes are still a valid PNG
35
+ * (image viewers display the rasterised pixels) and the Annot editor
36
+ * treats it as a normal PNG (no `<annot:annotations>` → not editable
37
+ * round-trip).
38
+ *
39
+ * Use this for "I want my CI failure screenshot to carry test-id /
40
+ * commit metadata" style provenance sidecars.
41
+ *
42
+ * Returns the input bytes unchanged if `tags` is empty.
43
+ */
44
+ export declare function writePngWithTagsOnly(pngData: Uint8Array, tags: Record<string, string>): Uint8Array;
45
+ /**
46
+ * Common tag keys written by built-in Annot producers. **Not validated**
47
+ * at write time — `createEditablePngBytes` accepts any string/string
48
+ * pair. Documented as a soft convention so that downstream readers can
49
+ * key off the same names when present.
50
+ *
51
+ * - `source` — what produced the PNG (e.g. `"docs-tour"`,
52
+ * `"playwright-fixture"`, `"annot-mcp"`).
53
+ * - `screen` — for living-product-docs, the `<Screen id>` value.
54
+ * - `capturedAt` — ISO timestamp.
55
+ * - `commit` — git SHA when applicable.
56
+ */
57
+ export declare const WELL_KNOWN_TAG_KEYS: readonly ["source", "screen", "capturedAt", "commit"];
58
+ export interface CreateEditablePngBytesOptions {
59
+ /** Rasterised PNG bytes — the visible image, with annotations already
60
+ * baked into the pixels. */
61
+ renderedPng: Uint8Array;
62
+ /** Original un-annotated capture. Pass either the raw PNG/JPEG bytes
63
+ * or a `data:` URL string. */
64
+ originalImage: Uint8Array | string;
65
+ /** Annotations-only SVG fragment (no `<svg>` wrapper required — this
66
+ * is what the editor's `readEditableImage` will reconstruct from). */
67
+ annotationsSvg: string;
68
+ /** Image width in pixels. Written into the XMP `<annot:width>` field. */
69
+ width: number;
70
+ /** Image height in pixels. Written into the XMP `<annot:height>` field. */
71
+ height: number;
72
+ /** Optional opaque kv tags. See {@link WELL_KNOWN_TAG_KEYS} for the
73
+ * soft convention. */
74
+ tags?: Record<string, string>;
75
+ }
76
+ /**
77
+ * Write a re-editable PNG: takes a rasterised PNG (visible image) and
78
+ * embeds the original capture + annotations SVG in the XMP / custom
79
+ * chunks. The Annot editor reads the metadata back via
80
+ * {@link readEditablePngBytes} / {@link readEditableImage} and
81
+ * reconstructs an editable document.
82
+ *
83
+ * The returned bytes are still a valid PNG — image viewers that don't
84
+ * know about the custom chunks display the rasterised pixels verbatim.
85
+ */
86
+ export declare function createEditablePngBytes(opts: CreateEditablePngBytesOptions): Uint8Array;
87
+ /** Parsed XMP metadata extracted from a re-editable image. */
88
+ export interface AnnotMetadata {
89
+ /** Original un-annotated capture re-emitted as a `data:` URL. Empty
90
+ * string when no original was embedded. MIME is inferred from the
91
+ * embedded bytes' magic header (PNG vs JPEG). */
92
+ originalImageDataUrl: string;
93
+ /** Annotations-only SVG fragment recovered from the XMP. */
94
+ annotationsSvg: string;
95
+ /** Image width in pixels. */
96
+ width: number;
97
+ /** Image height in pixels. */
98
+ height: number;
99
+ /** Opaque kv tags. Empty object when the XMP carried no `<annot:tags>`
100
+ * element or the embedded JSON was malformed. */
101
+ tags: Record<string, string>;
102
+ }
103
+ /**
104
+ * Read XMP metadata from a re-editable PNG.
105
+ *
106
+ * Returns `null` when the bytes aren't a PNG, when the PNG carries no
107
+ * Annot iTXt chunk, or when the XMP is missing the required
108
+ * `<annot:annotations>` field. For format-agnostic reading (PNG OR
109
+ * JPEG), use {@link readEditableImage}.
110
+ */
111
+ export declare function readEditablePngBytes(data: Uint8Array): AnnotMetadata | null;
112
+ /**
113
+ * Read XMP metadata from a re-editable image — PNG or JPEG. Returns
114
+ * `null` for any other format, for files without the Annot custom
115
+ * metadata, and for files whose XMP is missing the required
116
+ * `<annot:annotations>` field.
117
+ */
118
+ export declare function readEditableImage(data: Uint8Array): AnnotMetadata | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingcreators/annot-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Annot SDK — SVG-first screenshot annotation format, storage abstractions, and editor primitives. The foundation shared by the PWA, Chrome extension, Electron desktop, VSCode extension, and the headless annotator.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "ingcreators",
@@ -45,14 +45,14 @@
45
45
  "@types/pako": "^2.0.4",
46
46
  "typescript": "^6.0.3",
47
47
  "vite": "^8.0.13",
48
- "vite-plugin-dts": "^5.0.0",
49
- "@ingcreators/annot-imagequant": "0.1.0"
48
+ "vite-plugin-dts": "^5.0.1"
50
49
  },
51
50
  "dependencies": {
52
51
  "pako": "^2.1.0"
53
52
  },
54
53
  "scripts": {
55
54
  "build": "vite build",
56
- "typecheck": "tsc --noEmit"
55
+ "typecheck": "tsc --noEmit",
56
+ "bench:quantize": "node --experimental-strip-types --no-warnings scripts/bench-quantize.ts"
57
57
  }
58
58
  }
package/styles/editor.css CHANGED
@@ -140,6 +140,14 @@ body {
140
140
  0 10px,
141
141
  10px -10px,
142
142
  -10px 0;
143
+ /* Hidden by default — only the image editor mounts here. Gallery,
144
+ * doc, split-editor, and capture workspaces each own their own
145
+ * surface. Without this default, the checkered canvas backdrop
146
+ * paints on every boot before JS decides the route, producing a
147
+ * brief "empty editor" flash on gallery loads (e.g. `/app`, `/`,
148
+ * `/folder/...`). `body.editor-mode` below restores display when
149
+ * `EditorSession.setupEditor` mounts an image. */
150
+ display: none;
143
151
  }
144
152
 
145
153
  /* Editor SVG root.
@@ -186,13 +194,21 @@ body {
186
194
  height: 28px;
187
195
  background: var(--annot-bg-panel, #1e1e1e);
188
196
  border-top: 1px solid var(--annot-border-color, #333);
189
- display: flex;
197
+ /* Hidden by default for the same reason as `#canvas-container`
198
+ * above — only the image editor mode shows the statusbar, and
199
+ * leaving it visible by default paints the dark bottom strip
200
+ * before JS resolves the route, contributing to the boot flash
201
+ * on gallery loads. `body.editor-mode` restores `display: flex`. */
202
+ display: none;
190
203
  align-items: center;
191
204
  padding: 0 12px;
192
205
  gap: 16px;
193
206
  font-size: 12px;
194
207
  color: #888;
195
208
  }
209
+ body.editor-mode #statusbar {
210
+ display: flex;
211
+ }
196
212
 
197
213
  /* Save toast notification */
198
214
  .save-toast {
@@ -970,6 +986,7 @@ body.editor-mode #editor-sidebar {
970
986
  overflow: visible;
971
987
  }
972
988
  body.editor-mode #canvas-container {
989
+ display: block;
973
990
  top: 48px; /* below editor-header only */
974
991
  left: 48px; /* right of the sidebar */
975
992
  }