@openpresentation/opf-editor 0.8.0 → 0.10.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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
4
4
 
5
- Version 0.8.0 requires `@openpresentation/opf` ^0.11.0, renderer ^0.9.0, and PPTX ^0.9.0. Named ColorRefs (scheme slots, roles, and `var:<id>`) paint through the published renderer and hex-resolve on export. Payload ids share the slide id namespace for pagination and transfer. Native `schemeClr`, theme write, and `p:hf` remain out of scope.
5
+ Version 0.10.0 requires `@openpresentation/opf` ^0.11.2, renderer ^0.11.0, and PPTX ^0.11.0 (install them together so cover centering resolves the same core everywhere; renderer 0.11.0 also activates the playground's automatic script fonts and lazy Intos fonts). Version 0.9.0 required `@openpresentation/opf` ^0.11.1, renderer ^0.10.0, and PPTX ^0.10.0. Composition and slide transfer fall back to the shared `aptos` font scheme and report unknown scheme ids through `onDiagnostic`; gallery apply keeps every font-scheme role.
6
+
7
+ Version 0.8.0 required `@openpresentation/opf` ^0.11.0, renderer ^0.9.0, and PPTX ^0.9.0. Named ColorRefs (scheme slots, roles, and `var:<id>`) paint through the published renderer and hex-resolve on export. Payload ids share the slide id namespace for pagination and transfer. Native `schemeClr`, theme write, and `p:hf` remain out of scope.
6
8
 
7
9
  Version 0.7.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
8
10
 
@@ -70,7 +72,7 @@ await canvas.ready;
70
72
  // canvas.destroy() when unmounting.
71
73
  ```
72
74
 
73
- Load the same font bytes into the browser using `loadBrowserFontRegistry` from `@openpresentation/opf-render/fonts-browser` before mounting. The host owns font URLs, storage and collaboration. `onDraft` provides live document drafts; the session only changes on commit. Escape cancels. Concurrent edits to the selected payload cancel a stale draft.
75
+ Load the same font bytes into the browser using `loadBrowserFontRegistry` from `@openpresentation/opf-render/fonts-browser` before mounting. The host owns font URLs, storage and collaboration. For non-Latin documents, pass the pinned script pack's location as `scriptBaseUrl` and call `fonts.ensureScripts(document)` after edits (renderer with `scripts: 'auto'`, FF-19): only the faces for the scripts a document draws are fetched, once, hash-verified, for example Noto Sans JP for Japanese; a Latin-only document fetches none. The playground does this from `./script-fonts/`, which `npm run build:playground` fills with the pinned faces. `onDraft` provides live document drafts; the session only changes on commit. Escape cancels. Concurrent edits to the selected payload cancel a stale draft.
74
76
 
75
77
  These APIs were introduced in 0.1.0. Version 0.7.0 requires core 0.10.0 and renderer 0.8.0 for the canvas, including shared accepted geometry, styled/merged cells, rich table values, headers and content-aware row heights. See the OPF repository’s `docs/live-editor.md` for setup, the support matrix and roadmap. `pnpm pack:ecosystem` in that repository also prepares local preview tarballs for coordinated development.
76
78
 
@@ -162,6 +164,43 @@ toolbar.append(themeSelect);
162
164
 
163
165
  Invalid IDs throw before they reach the document. Existing object-form references keep their sibling override fields and replace only `id`.
164
166
 
167
+ ## Dimension switches
168
+
169
+ `@openpresentation/opf-editor/switches` turns "switch this pptx.gallery dimension to X" into one validated, undoable transaction. It covers the 14 gallery dimensions (FF-16, [font-fidelity-everywhere](https://github.com/OpenPresentation/opf/tree/main/docs/programs/font-fidelity-everywhere)):
170
+
171
+ ```js
172
+ import { switchDimension, prepareDimensionSwitch, SWITCH_DIMENSIONS } from "@openpresentation/opf-editor/switches";
173
+
174
+ switchDimension(editor, "font-schemes", "georgia"); // /design/fontScheme
175
+ switchDimension(editor, "charts", "line", { slideIndex: 1 }); // /slides/1/chart/type
176
+ switchDimension(editor, "blocks", "list", { path: "slides.2.blocks.0" }); // replace one block
177
+ editor.undo(); // one step per switch
178
+ const { patches, document } = prepareDimensionSwitch(editor.document, "themes", "classic"); // preview only
179
+ ```
180
+
181
+ | Dimension | Document patch | Notes |
182
+ | --- | --- | --- |
183
+ | `layouts` | `/slides/N/layout` | Needs `slideIndex`. Adds the blank payloads the layout declares, like the JSON editor's layout choice; existing content stays. |
184
+ | `color-schemes`, `font-schemes` | `/design/colorScheme`, `/design/fontScheme` | Deck by default, one slide with `slideIndex`. An inline object value is replaced by the bare id. |
185
+ | `themes` | `/design/theme` plus the theme's color scheme, font scheme, background and dimensions | Writes the whole bundle, as the gallery's theme snippet does, so fonts follow. `bundle: false` changes only the id. |
186
+ | `languages`, `narratives`, `tones`, `audiences` | `/language`, `/narrative`, `/tone`, `/audience` | Catalog ids; `audiences` accepts an id or an array. |
187
+ | `backgrounds` | `/design/background` | A background object or a shorthand string (theme slot such as `dark1`, or a hex color). |
188
+ | `headers-footers` | `/design/header`, `/design/footer` | `{header?, footer?}`: an absent field stays, `null` removes it. |
189
+ | `image-treatments` | `/design/slideImage`, `/design/imageFill` | `{slideImage?, imageFill?}`, same rule. |
190
+ | `socials` | `/speaker/socials/<platform>` or the `organization` | `{platform, handle}` with `owner` and `index`; the owner must exist. |
191
+ | `charts` | `<chart owner>/chart/type` | The slide's first chart, or the block named by `path`. The data is kept as it is and only the document schema is checked: the editor does not verify that the data suits the new type, so preview the result (map types, for example, expect their own data). |
192
+ | `blocks` | replaces one block | `path` names a complete `blocks/N` block or a slide/region with one content field. The value is a block kind or a block object. |
193
+
194
+ A deck-level design switch cannot reach a slide that carries its own value for that key. The result lists those slides in `shadowed`; `clearSlideOverrides: true` removes the overrides in the same transaction. `record` adds a gallery item's catalog record inline in the same transaction when neither the document nor the bundled catalog defines its id (a gallery-only layout or font scheme). Every switch is validated: an unknown catalog id, an invalid value or an invalid resulting document throws before anything changes, and switching to the current value commits nothing. The editor session emits the usual `patch`, `undo` and `redo` events with `meta.source: "dimension-switch"` and `meta.dimension`, so the canvas and any host preview recompose from the switched document. `resolveSlideFonts(document, slideIndex)` returns the heading, body and code families the preview measures and the export names.
195
+
196
+ ### Content-type conversion: replacement only (FF-16 decision)
197
+
198
+ The editor does not convert one content type into another. `blocks` is block replacement only: the old payload is discarded (text is not turned into list items, a list into a chart, and so on) and the block keeps only its `id` and `extensions`. Author the replacement content explicitly, or insert and remove blocks. This release provides no conversion API.
199
+
200
+ ### What a switch does not establish
201
+
202
+ A switch changes the document; it does not change what the engines support. Language changes recompose fonts only as far as the installed core, renderer and PPTX packages implement the language and script model (FF-18, FF-19); the editor's own composition measures the Latin families. `image-treatments` previews only where the installed renderer draws `design.slideImage`. `test/switches.mjs` checks the patch, one undo step, undo/redo, and preview refresh for all 14 dimensions. `test/switches-export.mjs` exports after each switch, undo and redo and applies opf-pptx's FF-08 typeface check (`checkPptxTypefaces`) when the installed package has it; set `OPF_REQUIRE_FF08=1` to fail instead of skip when it does not. Published opf-pptx 0.9.1 does not include it.
203
+
165
204
  ## Optional React Bindings
166
205
 
167
206
  React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
@@ -198,21 +237,17 @@ Svelte bindings are isolated under `@openpresentation/opf-editor/svelte` as acti
198
237
 
199
238
  ## Release Lane
200
239
 
201
- Public npm package publication is handled by `.github/workflows/release.yml` with npm provenance.
202
-
203
- Required first-publish setup:
204
-
205
- 1. An npm owner for the `@openpresentation` scope must run the first publish or reserve/grant the `@openpresentation/opf-editor` package.
206
- 2. Configure npm Trusted Publishing for GitHub repository `OpenPresentation/opf-editor` and workflow `.github/workflows/release.yml`.
207
- 3. Publish by creating a GitHub Release or manually running the Release workflow after CI passes.
240
+ Public npm package publication is handled by `.github/workflows/release.yml` through npm Trusted Publishing (GitHub Actions OIDC) with npm provenance; no npm token is stored. The owner authorized agents to prepare and publish npm releases whenever a release is required (2026-09-29). This authorization does not waive any gate.
208
241
 
209
- This repo does not require an npm automation token when Trusted Publishing is configured.
242
+ 1. Open a release-prep PR containing only the version bump, `CHANGELOG.md`, dependency ranges, lockfile and current-instruction docs. Publish in dependency order (core, then renderer, then PPTX, then editor): refresh this repo's lockfile only after the required `@openpresentation/opf`, `@openpresentation/opf-render` and `@openpresentation/opf-pptx` versions are on the registry (`npm install --package-lock-only`), then run `npm run test:packed` against them.
243
+ 2. Merge after CI is green, then publish by pushing the git tag `opf-editor-v<version>` (or `@openpresentation/opf-editor@v<version>`) at the merge commit. The workflow verifies that the tag matches `package.json` and reruns audit, typecheck, validate, tests, playground, code/JSON browser and packed checks before `npm publish --access public --provenance`. A manual `workflow_dispatch` runs the same job without the tag check and is a fallback only.
244
+ 3. Verify with `npm view @openpresentation/opf-editor@<version> version gitHead dist.attestations` and, from the core repo, `node scripts/test-editor-publication.mjs <version> <release-commit> <this-checkout>`. Never republish an existing version.
210
245
 
211
246
  ## Shared dynamic composition
212
247
 
213
248
  The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
214
249
 
215
- Version 0.8.0 requires `@openpresentation/opf@^0.11.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.9.0`. Coordinated playground export uses `@openpresentation/opf-pptx@^0.9.0`. Clean registry installs support the composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
250
+ Version 0.10.0 requires `@openpresentation/opf@^0.11.2`. The optional renderer peer requires `@openpresentation/opf-render@^0.11.0`. Coordinated playground export uses `@openpresentation/opf-pptx@^0.11.0`. Clean registry installs support the composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
216
251
 
217
252
  ## Local interactive demo
218
253
 
@@ -224,7 +259,7 @@ Nested content groups expose their bounds through `editor.composeSlide(index).gr
224
259
 
225
260
  `editor.paginateSlide(index, {minFontSize:24})` splits a crowded draft into ordinary OPF slides as one undoable transaction. It returns the change and source mappings; failed pagination leaves the document unchanged. The playground’s Split overflow action demonstrates the workflow.
226
261
 
227
- `composeSlide(index, {textMeasurement})` and `paginateSlide(index, {textMeasurement})` accept the same font provider as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions.
262
+ `composeSlide(index, {textMeasurement})` and `paginateSlide(index, {textMeasurement})` accept the same font provider as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions. The font a user selects (for example Calibri or Aptos) is the source of truth and stays in the document; because license-restricted (proprietary) fonts are never bundled, the canvas draws an open look-alike (a metric-compatible one such as Carlito for Calibri where it exists, a visual-only fallback for Aptos today) and the substitution report says so. Release caveat: the published editor depends on opf-pptx `^0.9.0`, which resolves 0.9.1 and still writes the substitute into the PPTX; selected-name export arrives with the next opf-pptx release. See the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31).
228
263
 
229
264
 
230
265
  ## Copy, paste, and gallery imports
@@ -235,7 +270,7 @@ The `/galleries` export provides `loadOpfGallery` and `loadOpfGalleryItem`, acce
235
270
 
236
271
  The playground includes Copy OPF and Import dialogs with paste, file/drop, gallery search, and URL tabs. Files accept OPF JSON and PowerPoint `.pptx` up to 20 MB. PPTX conversion runs locally using `@openpresentation/opf-pptx`; review the converted preview and diagnostics before inserting slides or opening a presentation. Applying an import is one undoable edit. Custom registries persist in local storage; defaults are configured in `examples/galleries.json`. Copy permissions may require using the Select all fallback. Pasting within text fields keeps normal text editing. Cmd/Ctrl+Shift+C opens copying and Cmd/Ctrl+O opens file import.
237
272
 
238
- The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. Native fonts must be installed in PowerPoint; the download does not embed font binaries. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
273
+ The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. With an opf-pptx that includes FF-31 (after 0.9.1), the download names the font the user selected, never the preview look-alike, so PowerPoint shows that font when it is installed or available as a Microsoft 365 cloud font; opf-pptx 0.9.1, which the published editor resolves today, still writes the substitute. The download does not embed font binaries, and license-restricted (proprietary) fonts are never embedded. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
239
274
 
240
275
  Run `npm ci`, `npm run build:playground`, then serve `artifacts/playground` with a local static server. This example uses the published core/render/PPTX packages and this checkout's editor source; it does not require an account, AI provider or paid service. `npm run test:playground` builds and executes a real browser flow (local Edge on Windows, installed Chromium in CI): author JSON, inline edit, export a native merged table, validate/reimport the actual downloaded file, undo/redo, save OPF and reject malformed input without changing the document. It is browser behavior evidence, not a claim of native PowerPoint raster equivalence.
241
276
 
package/dist/blocks.d.ts CHANGED
@@ -9,5 +9,7 @@ export interface PreparedBlockChange { document:unknown;patches:JsonPatchOperati
9
9
  export declare function prepareBlockInsert(document:unknown,containerPath:string,block:unknown,index?:number):PreparedBlockChange;
10
10
  export declare function prepareBlockDuplicate(document:unknown,path:string):PreparedBlockChange;
11
11
  export declare function prepareBlockRemove(document:unknown,path:string):PreparedBlockChange;
12
+ /** Replace one complete block (or a slide/region's single content field). Replacement only: the old payload is discarded and nothing is converted between content types. Keeps `id` and `extensions` when the new block sets none. */
13
+ export declare function prepareBlockReplace(document:unknown,path:string,block:unknown):PreparedBlockChange;
12
14
  export type ContentBlockKind='text'|'list'|'chart'|'table'|'metric'|'quote'|'code'|'timeline'|'group'|'image'|'video';
13
15
  export declare function createContentBlock(kind:ContentBlockKind,options?:{source?:string}):Record<string,unknown>;
package/dist/blocks.js CHANGED
@@ -83,6 +83,39 @@ function checkedBlock(document,path) {
83
83
  if(index>=blocks.length)throw new RangeError('Block does not exist.');
84
84
  return {parts,container,index,blocks};
85
85
  }
86
+ /**
87
+ * Replace one complete block with another block. This is block replacement only (FF-16):
88
+ * the old payload is discarded and nothing is converted into the new content type. The
89
+ * block keeps its `id` and `extensions` when the new block sets none. An implicit slide or
90
+ * region payload (exactly one content field, no `blocks`) is replaced in place, keeping its
91
+ * other slide fields. The test guard fails if the block changed since it was read.
92
+ */
93
+ export function prepareBlockReplace(document,path,block) {
94
+ if(!block||typeof block!=='object'||Array.isArray(block))throw new TypeError('Replace with an OPF content block object.');
95
+ const parts=splitOpfPath(path),pointer=opfPathToJsonPointer(parts);
96
+ const explicit=parts.at(-2)==='blocks'&&/^(0|[1-9][0-9]*)$/.test(parts.at(-1)??'');
97
+ let old,next;
98
+ if(explicit){
99
+ const {index,blocks}=checkedBlock(document,path);
100
+ old=blocks[index];
101
+ next=structuredClone(block);
102
+ for(const key of ['id','extensions'])if(next[key]===undefined&&old[key]!==undefined)next[key]=structuredClone(old[key]);
103
+ } else {
104
+ const implicit=listBlockContainers(document,{includeImplicit:true}).find(c=>c.path===pointer&&c.implicit);
105
+ if(!implicit)throw new TypeError('Choose a complete block using its blocks/index path, or a slide or region with one content field.');
106
+ if(implicit.count!==1)throw new TypeError('This slide or region holds several content fields. Choose one block, or insert and remove blocks.');
107
+ old=getValueAtPath(document,parts);
108
+ next=structuredClone(old);
109
+ for(const key of [...contentFields,'type'])delete next[key];
110
+ // The block's own id and extensions would overwrite the slide's; they are not carried over.
111
+ const {id:ignoredId,extensions:ignoredExtensions,...payload}=structuredClone(block);
112
+ Object.assign(next,payload);
113
+ }
114
+ const patches=[{op:'test',path:pointer,value:structuredClone(old)},{op:'replace',path:pointer,value:next}];
115
+ const result=applyJsonPatch(document,patches),validation=validateOpfDocument(result);
116
+ if(!validation.valid)throw new Error(validation.errors[0]?.message??'The replaced block is not valid OPF.');
117
+ return {document:result,patches,path:pointer,changed:JSON.stringify(old)!==JSON.stringify(next)};
118
+ }
86
119
  /** Duplicate the entire block immediately after itself, preserving formatting and asset references. */
87
120
  export function prepareBlockDuplicate(document,path) {
88
121
  const {container,index,blocks}=checkedBlock(document,path);
package/dist/canvas.d.ts CHANGED
@@ -54,6 +54,15 @@ export interface CanvasEditor {
54
54
  setRenderOptions(options: RenderSvgOptions): boolean;
55
55
  destroy(): void;
56
56
  }
57
+ export declare function allocatedSelectionBox(
58
+ node: {
59
+ dataset?: DOMStringMap | Record<string, string>;
60
+ hasAttribute?(name: string): boolean;
61
+ getAttribute?(name: string): string | null;
62
+ getBBox?(): { x: number; y: number; width: number; height: number };
63
+ },
64
+ item?: { box?: { x: number; y: number; width: number; height: number } },
65
+ ): { x: number; y: number; width: number; height: number };
57
66
  export declare function createCanvasEditor(
58
67
  container: HTMLElement,
59
68
  options: CanvasEditorOptions,
package/dist/canvas.js CHANGED
@@ -21,6 +21,38 @@ import { createRichTextInput } from "./rich-text-input.js";
21
21
  import { createRichTextToolbar } from "./rich-text-toolbar.js";
22
22
  export { getEditableFields } from "./canvas-fields.js";
23
23
 
24
+ /** Allocated placeholder or internal-part bounds for the selection outline, not glyph ink. */
25
+ export function allocatedSelectionBox(node, item) {
26
+ const traced = {
27
+ x: Number(node.dataset?.opfBoxX),
28
+ y: Number(node.dataset?.opfBoxY),
29
+ width: Number(node.dataset?.opfBoxWidth),
30
+ height: Number(node.dataset?.opfBoxHeight),
31
+ };
32
+ const hasTraced = [traced.x, traced.y, traced.width, traced.height].every(Number.isFinite) && (traced.width > 0 || traced.height > 0);
33
+ const partRole = typeof node.hasAttribute === "function" && (
34
+ node.hasAttribute("data-opf-code-role") ||
35
+ node.hasAttribute("data-opf-metric-role") ||
36
+ node.hasAttribute("data-opf-source-text")
37
+ );
38
+ if (partRole && hasTraced) return traced;
39
+ if (item?.box && [item.box.x, item.box.y, item.box.width, item.box.height].every(Number.isFinite)) {
40
+ return { x: item.box.x, y: item.box.y, width: item.box.width, height: item.box.height };
41
+ }
42
+ if (hasTraced) return traced;
43
+ let bounds = typeof node.getBBox === "function" ? node.getBBox() : { x: 0, y: 0, width: 0, height: 0 };
44
+ const lines = JSON.parse(node.getAttribute?.("data-opf-rich-lines") ?? "[]");
45
+ if (!bounds.width && !bounds.height && lines.length) {
46
+ return {
47
+ x: lines[0].x,
48
+ y: lines[0].y,
49
+ width: Number(node.getAttribute("data-opf-box-width")) || 8,
50
+ height: lines.reduce((sum, line) => sum + line.height, 0),
51
+ };
52
+ }
53
+ return bounds;
54
+ }
55
+
24
56
  /** A framework-independent SVG canvas. Drafts render immediately; each edit commits once. */
25
57
  export function createCanvasEditor(container, options = {}) {
26
58
  if (!container?.ownerDocument)
@@ -160,10 +192,7 @@ export function createCanvasEditor(container, options = {}) {
160
192
  `Edit ${path.split(".").at(-1)}: ${["string", "number"].includes(typeof value) ? String(value).slice(0, 80) : "content properties"}`,
161
193
  );
162
194
  if (node.matches("g")) {
163
- let bounds = node.getBBox();
164
- if (!bounds.width && !bounds.height && (node.hasAttribute('data-opf-code-role')||node.hasAttribute('data-opf-metric-role')||node.hasAttribute('data-opf-source-text'))) bounds={x:Number(node.dataset.opfBoxX),y:Number(node.dataset.opfBoxY),width:Number(node.dataset.opfBoxWidth),height:Number(node.dataset.opfBoxHeight)};
165
- const lines = JSON.parse(node.getAttribute("data-opf-rich-lines") ?? "[]");
166
- if (!bounds.width && !bounds.height && lines.length) bounds = {x:lines[0].x,y:lines[0].y,width:Number(node.getAttribute("data-opf-box-width"))||8,height:lines.reduce((sum,line)=>sum+line.height,0)};
195
+ const bounds = allocatedSelectionBox(node, item);
167
196
  const rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
168
197
  for (const [key, value] of Object.entries({
169
198
  x: bounds.x - 4,
@@ -0,0 +1,318 @@
1
+ import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
2
+
3
+ // Exact-source memory. Editing a slide rewrites only the tokens whose values changed, but a
4
+ // value that later returns (Escape, Undo, Redo) would otherwise be re-serialized with
5
+ // JSON.stringify, which turns an authored `"Café \/ Q1"` into `"Café / Q1"`. The memory
6
+ // keeps the exact source bytes of documents that were seen, keyed by the parsed value.
7
+ //
8
+ // Limits: a source longer than MAX_EXACT_SOURCE_LENGTH characters is not remembered, and the
9
+ // memory holds at most 64 sources and 8 MB in total (oldest evicted first). Edits keep working
10
+ // beyond those limits, but restoring an edited value then uses normalized JSON spelling; the
11
+ // memory reports this through `memory.limited` so a host can tell the author.
12
+ export const MAX_EXACT_SOURCE_LENGTH = 2_000_000;
13
+ const DEFAULT_LIMIT = 64;
14
+ const DEFAULT_MAX_BYTES = 8_000_000;
15
+
16
+ /**
17
+ * `last` is the last generated document and, when it came from typing into one existing string
18
+ * value, that value's path. A further edit of the same value replaces that transient state
19
+ * instead of crowding out older ones, so a long draft cannot evict the authored spelling.
20
+ * Discrete edits (add, remove, move) never coalesce.
21
+ */
22
+ export function createSourceMemory(limit = DEFAULT_LIMIT, maxBytes = DEFAULT_MAX_BYTES) {
23
+ return { entries: new Map(), bytes: 0, last: null, limit, maxBytes, limited: false };
24
+ }
25
+
26
+ const sharedMemory = createSourceMemory();
27
+
28
+ function forget(memory, key) {
29
+ const source = memory.entries.get(key);
30
+ if (source === undefined) return;
31
+ memory.bytes -= source.length;
32
+ memory.entries.delete(key);
33
+ }
34
+
35
+ function remember(memory, key, source) {
36
+ forget(memory, key);
37
+ if (source.length > MAX_EXACT_SOURCE_LENGTH) return;
38
+ memory.entries.set(key, source);
39
+ memory.bytes += source.length;
40
+ while ((memory.entries.size > memory.limit || memory.bytes > memory.maxBytes) && memory.entries.size > 1) {
41
+ forget(memory, memory.entries.keys().next().value);
42
+ }
43
+ }
44
+
45
+ function recall(memory, key) {
46
+ const source = memory.entries.get(key);
47
+ if (source === undefined) return undefined;
48
+ try {
49
+ if (JSON.stringify(JSON.parse(source)) === key) {
50
+ memory.entries.delete(key);
51
+ memory.entries.set(key, source);
52
+ return source;
53
+ }
54
+ } catch { /* An entry that no longer parses to this value is discarded. */ }
55
+ forget(memory, key);
56
+ return undefined;
57
+ }
58
+
59
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
60
+ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
61
+ const canonical = (value) => JSON.stringify(value, (_name, item) => isObject(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => compare(a, b))) : item);
62
+
63
+ /** The first duplicate object key in JSON source, as `{ key, path }`, or null. */
64
+ export function findDuplicateKey(source) {
65
+ const tree = parseTree(source);
66
+ function walk(node, path) {
67
+ if (node.type === "object") {
68
+ const seen = new Set();
69
+ for (const property of node.children ?? []) {
70
+ const name = property.children?.[0]?.value;
71
+ if (seen.has(name)) return { key: name, path: [...path] };
72
+ seen.add(name);
73
+ const found = property.children?.[1] && walk(property.children[1], [...path, name]);
74
+ if (found) return found;
75
+ }
76
+ } else if (node.type === "array") {
77
+ for (const [index, child] of (node.children ?? []).entries()) {
78
+ const found = walk(child, [...path, index]);
79
+ if (found) return found;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ return tree ? walk(tree, []) : null;
85
+ }
86
+
87
+ const own = (object, name) => (Object.hasOwn(object, name) ? object[name] : undefined);
88
+ const text = (value) => JSON.stringify(value);
89
+
90
+ // Value differences as edit operations, all in the coordinates of the source being edited:
91
+ // replace a leaf or subtree gets a new token
92
+ // copy a slot of a same-length array takes the exact token of an equal element (a move)
93
+ // remove an array element or object key is removed
94
+ // insert an array element or object key is added
95
+ // Reading goes through own properties only, so keys such as "__proto__" or "constructor" are
96
+ // ordinary keys.
97
+ function diff(before, after, path, ops) {
98
+ if (text(before) === text(after)) return;
99
+ if (Array.isArray(before) && Array.isArray(after)) {
100
+ const beforeKeys = before.map(text), afterKeys = after.map(text);
101
+ if (before.length === after.length) {
102
+ const holders = new Map();
103
+ beforeKeys.forEach((key, index) => { if (!holders.has(key)) holders.set(key, index); });
104
+ after.forEach((value, index) => {
105
+ if (beforeKeys[index] === afterKeys[index]) return;
106
+ const from = holders.get(afterKeys[index]);
107
+ if (from !== undefined) ops.push({ kind: "copy", path: [...path, index], from: [...path, from] });
108
+ else diff(before[index], value, [...path, index], ops);
109
+ });
110
+ return;
111
+ }
112
+ let head = 0;
113
+ while (head < before.length && head < after.length && beforeKeys[head] === afterKeys[head]) head++;
114
+ let tail = 0;
115
+ while (tail < before.length - head && tail < after.length - head && beforeKeys[before.length - 1 - tail] === afterKeys[after.length - 1 - tail]) tail++;
116
+ const removed = before.length - head - tail, inserted = after.length - head - tail, paired = Math.min(removed, inserted);
117
+ for (let offset = 0; offset < paired; offset++) diff(before[head + offset], after[head + offset], [...path, head + offset], ops);
118
+ for (let offset = removed - 1; offset >= paired; offset--) ops.push({ kind: "remove", path: [...path, head + offset] });
119
+ for (let offset = paired; offset < inserted; offset++) ops.push({ kind: "insert", path: [...path, head + offset], value: after[head + offset] });
120
+ } else if (isObject(before) && isObject(after)) {
121
+ for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
122
+ const a = own(before, name), b = own(after, name);
123
+ if (a !== undefined && b !== undefined) diff(a, b, [...path, name], ops);
124
+ else if (a !== undefined) ops.push({ kind: "remove", path: [...path, name] });
125
+ else if (b !== undefined) ops.push({ kind: "insert", path: [...path, name], value: b });
126
+ }
127
+ } else ops.push({ kind: "replace", path, value: after });
128
+ }
129
+
130
+ // The smallest nodes that contain every edit: a changed value, or the object or array that gains
131
+ // or loses a child.
132
+ const maskPaths = (ops) => ops.map((op) => (op.kind === "remove" || op.kind === "insert" ? op.path.slice(0, -1) : op.path));
133
+
134
+ function masked(source, paths) {
135
+ const tree = parseTree(source);
136
+ if (!tree) return null;
137
+ const ranges = [];
138
+ for (const path of paths) {
139
+ const node = findNodeAtLocation(tree, path);
140
+ if (!node) return null;
141
+ ranges.push([node.offset, node.offset + node.length]);
142
+ }
143
+ ranges.sort((a, b) => a[0] - b[0]);
144
+ let output = "", cursor = 0;
145
+ for (const [start, end] of ranges) {
146
+ if (start < cursor) { cursor = Math.max(cursor, end); continue; }
147
+ output += source.slice(cursor, start) + "\u0000";
148
+ cursor = end;
149
+ }
150
+ return output + source.slice(cursor);
151
+ }
152
+
153
+ // A remembered source may replace the current one only when both are byte-equal outside the
154
+ // edited nodes: manual respellings elsewhere are never reverted.
155
+ function equalOutside(current, remembered, paths) {
156
+ const a = masked(current, paths);
157
+ return a !== null && a === masked(remembered, paths);
158
+ }
159
+
160
+ function rememberedToken(remembered, tree, path, value) {
161
+ if (!tree) return undefined;
162
+ const node = findNodeAtLocation(tree, path);
163
+ if (!node) return undefined;
164
+ const token = remembered.slice(node.offset, node.offset + node.length);
165
+ try { if (canonical(JSON.parse(token)) === canonical(value)) return token; } catch { /* Not the same value. */ }
166
+ return undefined;
167
+ }
168
+
169
+ function layout(source) {
170
+ const indent = source.match(/^[\t ]+(?=\S)/m)?.[0] ?? " ";
171
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
172
+ return { indent, eol, formattingOptions: { insertSpaces: !indent.includes("\t"), tabSize: indent.length, eol } };
173
+ }
174
+
175
+ // Insert a child (array element or object property) next to its siblings without reformatting
176
+ // them: copy a representative separator, indentation included, from between two siblings.
177
+ function insertChild(source, container, index, render) {
178
+ const children = container.children ?? [];
179
+ if (!children.length) return null;
180
+ const end = (node) => node.offset + node.length;
181
+ let separator;
182
+ if (children.length > 1) {
183
+ const at = Math.min(Math.max(index - 1, 0), children.length - 2);
184
+ separator = source.slice(end(children[at]), children[at + 1].offset);
185
+ } else {
186
+ const gap = source.slice(container.offset + 1, children[0].offset);
187
+ separator = gap.includes("\n") ? "," + gap : ", ";
188
+ }
189
+ if (!/^[\s,]*$/.test(separator) || !separator.includes(",")) return null;
190
+ const multiline = separator.includes("\n");
191
+ const indent = multiline ? separator.slice(separator.lastIndexOf("\n") + 1) : "";
192
+ const item = render(indent, multiline);
193
+ if (index < children.length) return source.slice(0, children[index].offset) + item + separator + source.slice(children[index].offset);
194
+ const last = end(children[children.length - 1]);
195
+ return source.slice(0, last) + separator + item + source.slice(last);
196
+ }
197
+
198
+ // Remove a child by its tree range, so the siblings keep their bytes: a last child goes with the
199
+ // separator before it, any other child with the separator after it, and an only child empties the
200
+ // container (`[]`, `{}`) instead of leaving a blank body.
201
+ function removeChild(source, path) {
202
+ const node = findNodeAtLocation(parseTree(source), path);
203
+ if (!node) throw new Error("Missing node");
204
+ const child = node.parent?.type === "property" ? node.parent : node;
205
+ const parent = child.parent;
206
+ const siblings = parent.children;
207
+ const index = siblings.indexOf(child);
208
+ const end = (item) => item.offset + item.length;
209
+ let from, to;
210
+ if (siblings.length === 1) { from = parent.offset + 1; to = end(parent) - 1; }
211
+ else if (index < siblings.length - 1) { from = child.offset; to = siblings[index + 1].offset; }
212
+ else { from = end(siblings[index - 1]); to = end(child); }
213
+ return source.slice(0, from) + source.slice(to);
214
+ }
215
+
216
+ // Typing into one existing string value is the only edit whose intermediate states are worth
217
+ // forgetting. Every discrete edit (add, remove, move) stays exactly restorable.
218
+ function typingSignature(previous, ops) {
219
+ if (ops.length !== 1 || ops[0].kind !== "replace" || typeof ops[0].value !== "string") return null;
220
+ let value = previous;
221
+ for (const step of ops[0].path) {
222
+ if (value === null || typeof value !== "object" || !Object.hasOwn(value, step)) return null;
223
+ value = value[step];
224
+ }
225
+ return typeof value === "string" ? JSON.stringify(ops[0].path) : null;
226
+ }
227
+
228
+ /**
229
+ * Change only the affected values. Keeps authored indentation, escaping and all unrelated
230
+ * metadata/content byte-for-byte, including across canvas Escape, Undo and Redo.
231
+ * Throws when `source` is not valid JSON. The result always parses to `document`; if an
232
+ * in-place edit cannot achieve that (for example the source repeats an object key), the whole
233
+ * document is written as indented JSON instead.
234
+ */
235
+ export function updateJsonSource(source, document, memory = sharedMemory) {
236
+ const previous = JSON.parse(source);
237
+ const previousKey = JSON.stringify(previous);
238
+ memory.limited = source.length > MAX_EXACT_SOURCE_LENGTH;
239
+ if (memory.entries.get(previousKey) !== source) {
240
+ // The current bytes were not produced here (initial source, typing, paste): they are the
241
+ // spelling to restore.
242
+ remember(memory, previousKey, source);
243
+ memory.last = null;
244
+ }
245
+ const ops = [];
246
+ diff(previous, document, [], ops);
247
+ if (!ops.length) return source;
248
+ const key = JSON.stringify(document);
249
+ const remembered = recall(memory, key);
250
+ if (remembered !== undefined && equalOutside(source, remembered, maskPaths(ops))) {
251
+ memory.last = null;
252
+ return remembered;
253
+ }
254
+ const rememberedTree = remembered === undefined ? null : parseTree(remembered);
255
+
256
+ const { indent, eol, formattingOptions } = layout(source);
257
+ const unit = indent.includes("\t") ? "\t" : indent;
258
+ const pretty = (value, lineIndent) => JSON.stringify(value, null, unit).split("\n").join(eol + lineIndent);
259
+ let result = source;
260
+ // Insertion copies the neighbours' separator; an empty container is written in the document's style.
261
+ function insert(path, value) {
262
+ const parentPath = path.slice(0, -1), last = path[path.length - 1];
263
+ const parent = findNodeAtLocation(parseTree(result), parentPath);
264
+ const isArray = typeof last === "number";
265
+ const container = parent && (parent.type === "array" || parent.type === "object");
266
+ const spliced = container
267
+ ? insertChild(result, parent, isArray ? last : parent.children?.length ?? 0, (lineIndent, multiline) => isArray
268
+ ? (multiline ? pretty(value, lineIndent) : text(value))
269
+ : `${text(last)}${multiline ? ": " : ":"}${multiline ? pretty(value, lineIndent) : text(value)}`)
270
+ : null;
271
+ if (spliced !== null) result = spliced;
272
+ else if (container && !parent.children?.length) {
273
+ const written = isArray ? [value] : Object.defineProperty({}, last, { value, enumerable: true, writable: true, configurable: true });
274
+ const lineIndent = result.slice(result.lastIndexOf("\n", parent.offset) + 1, parent.offset).match(/^[\t ]*/)[0];
275
+ const content = result.includes("\n") ? pretty(written, lineIndent) : text(written);
276
+ result = result.slice(0, parent.offset) + content + result.slice(parent.offset + parent.length);
277
+ } else result = applyEdits(result, modify(result, path, value, { formattingOptions, isArrayInsertion: isArray }));
278
+ }
279
+ try {
280
+ // Replacements and moves are position-independent: parse once and apply them back to front.
281
+ const tree = parseTree(source);
282
+ const edits = [];
283
+ for (const op of ops) {
284
+ if (op.kind !== "replace" && op.kind !== "copy") continue;
285
+ const node = findNodeAtLocation(tree, op.path);
286
+ if (!node) throw new Error("Missing node");
287
+ let token;
288
+ if (op.kind === "copy") {
289
+ const from = findNodeAtLocation(tree, op.from);
290
+ if (!from) throw new Error("Missing node");
291
+ token = source.slice(from.offset, from.offset + from.length);
292
+ } else token = rememberedToken(remembered, rememberedTree, op.path, op.value) ?? text(op.value);
293
+ edits.push({ from: node.offset, to: node.offset + node.length, token });
294
+ }
295
+ edits.sort((a, b) => b.from - a.from);
296
+ for (const edit of edits) result = result.slice(0, edit.from) + edit.token + result.slice(edit.to);
297
+ // Structural edits follow, in the order they were produced.
298
+ for (const op of ops) {
299
+ if (op.kind === "remove") result = removeChild(result, op.path);
300
+ else if (op.kind === "insert") insert(op.path, op.value);
301
+ }
302
+ } catch { result = ""; }
303
+ let valid = false;
304
+ try {
305
+ const parsed = JSON.parse(result);
306
+ valid = JSON.stringify(parsed) === key || canonical(parsed) === canonical(JSON.parse(key));
307
+ } catch { /* Fall back below. */ }
308
+ if (!valid) {
309
+ result = JSON.stringify(document, null, indent).replaceAll("\n", eol);
310
+ if (/\n$/.test(source)) result += eol;
311
+ }
312
+
313
+ const signature = typingSignature(previous, ops);
314
+ if (signature !== null && memory.last && memory.last.key === previousKey && memory.last.signature === signature) forget(memory, previousKey);
315
+ remember(memory, key, result);
316
+ memory.last = { key, signature };
317
+ return result;
318
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Last-resort font scheme id, shared with core pagination, opf-render and opf-pptx
3
+ * (`DEFAULT_FONT_SCHEME` in `@openpresentation/opf`). It applies only when neither the
4
+ * slide, the deck nor the resolved theme names a font scheme, so the editor measures and
5
+ * freezes the same fonts that preview and PowerPoint export use.
6
+ * Kept local until the published core exports the constant; a test asserts they agree.
7
+ */
8
+ export const DEFAULT_FONT_SCHEME = "aptos";
9
+
10
+ /**
11
+ * Resolve a font-scheme reference by the shared rule (`resolveFontSchemeReference` in
12
+ * `@openpresentation/opf`). An id that matches no record yields one
13
+ * `unresolved-font-scheme` diagnostic and uses the DEFAULT_FONT_SCHEME record as the base,
14
+ * with sibling overrides on top. Kept local until a published core exports the function;
15
+ * a test compares both whenever the installed core has it.
16
+ */
17
+ export function resolveFontSchemeReference(reference, lookup, path = "design.fontScheme") {
18
+ const overrides = reference && typeof reference === "object" && !Array.isArray(reference) ? reference : undefined;
19
+ const id = typeof reference === "string" ? reference : typeof overrides?.id === "string" ? overrides.id : undefined;
20
+ const found = id === undefined ? undefined : lookup(id);
21
+ const base = found ?? lookup(DEFAULT_FONT_SCHEME) ?? {};
22
+ const scheme = overrides ? { ...base, ...overrides } : { ...base };
23
+ if (id === undefined || found !== undefined) return { scheme };
24
+ return { scheme, diagnostic: { code: "unresolved-font-scheme", path, id, fallback: DEFAULT_FONT_SCHEME, message: `Font scheme '${id}' is not in the inline or bundled catalogs; using the default font scheme '${DEFAULT_FONT_SCHEME}'.` } };
25
+ }