@openpresentation/opf-editor 0.12.1 → 0.14.2

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.
Files changed (95) hide show
  1. package/README.md +71 -60
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +15 -15
  6. package/dist/background-options.d.ts +2 -2
  7. package/dist/background-options.js +6 -6
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +22 -21
  11. package/dist/blocks.d.ts +8 -8
  12. package/dist/blocks.js +41 -40
  13. package/dist/canvas-fields.js +35 -12
  14. package/dist/canvas.d.ts +22 -30
  15. package/dist/canvas.js +57 -64
  16. package/dist/chart-data.d.ts +10 -10
  17. package/dist/chart-data.js +21 -21
  18. package/dist/chart-options-panel.d.ts +2 -2
  19. package/dist/chart-options-panel.js +82 -8
  20. package/dist/chart-options.d.ts +35 -9
  21. package/dist/chart-options.js +133 -40
  22. package/dist/checks.js +21 -0
  23. package/dist/color-authoring.js +1 -1
  24. package/dist/content-actions.d.ts +15 -15
  25. package/dist/content-actions.js +71 -70
  26. package/dist/content-controls.js +12 -12
  27. package/dist/data-grid.js +7 -8
  28. package/dist/data.d.ts +4 -4
  29. package/dist/data.js +5 -5
  30. package/dist/dataset-refs.d.ts +2 -2
  31. package/dist/dataset-refs.js +5 -5
  32. package/dist/design-controls.js +61 -44
  33. package/dist/design-options.d.ts +16 -14
  34. package/dist/design-options.js +86 -74
  35. package/dist/edit-helpers.js +8 -7
  36. package/dist/exact-source.js +5 -5
  37. package/dist/export.d.ts +5 -7
  38. package/dist/export.js +40 -14
  39. package/dist/find-panel.js +5 -5
  40. package/dist/find-replace.d.ts +3 -3
  41. package/dist/find-replace.js +23 -20
  42. package/dist/font-gate.js +49 -58
  43. package/dist/galleries.js +36 -52
  44. package/dist/grid-model.d.ts +25 -27
  45. package/dist/grid-model.js +92 -102
  46. package/dist/image-crop.d.ts +4 -4
  47. package/dist/image-crop.js +20 -20
  48. package/dist/image-cropper.js +3 -3
  49. package/dist/index.d.ts +28 -28
  50. package/dist/index.js +74 -90
  51. package/dist/json-options.js +17 -16
  52. package/dist/layout-handles.js +5 -5
  53. package/dist/layout-placeholders.js +4 -4
  54. package/dist/layout.d.ts +2 -2
  55. package/dist/layout.js +8 -7
  56. package/dist/numbering-panel.js +3 -5
  57. package/dist/numbering.d.ts +3 -5
  58. package/dist/numbering.js +14 -26
  59. package/dist/outline-view.js +24 -24
  60. package/dist/outline.d.ts +12 -12
  61. package/dist/outline.js +52 -50
  62. package/dist/persistence.d.ts +1 -1
  63. package/dist/persistence.js +19 -18
  64. package/dist/presentation-ids.js +2 -2
  65. package/dist/react.js +3 -3
  66. package/dist/review-panel.d.ts +30 -13
  67. package/dist/review-panel.js +154 -53
  68. package/dist/review.d.ts +36 -43
  69. package/dist/review.js +92 -53
  70. package/dist/rich-text-input.js +4 -3
  71. package/dist/rich-text-toolbar.js +5 -3
  72. package/dist/rich-text.js +5 -2
  73. package/dist/schema-inspector.d.ts +2 -2
  74. package/dist/schema-inspector.js +16 -11
  75. package/dist/schema.d.ts +1 -1
  76. package/dist/schema.js +25 -5
  77. package/dist/slide-manager.d.ts +2 -2
  78. package/dist/slide-manager.js +18 -18
  79. package/dist/slides.d.ts +20 -20
  80. package/dist/slides.js +99 -97
  81. package/dist/svelte.js +1 -1
  82. package/dist/switches.d.ts +10 -10
  83. package/dist/switches.js +124 -114
  84. package/dist/table-options.d.ts +8 -8
  85. package/dist/table-options.js +28 -27
  86. package/dist/table-structure.d.ts +10 -10
  87. package/dist/table-structure.js +21 -21
  88. package/dist/template-panel.d.ts +3 -3
  89. package/dist/template-panel.js +37 -13
  90. package/dist/templates.d.ts +22 -11
  91. package/dist/templates.js +48 -55
  92. package/dist/transfer.d.ts +4 -4
  93. package/dist/transfer.js +49 -59
  94. package/package.json +8 -6
  95. package/dist/font-defaults.js +0 -25
package/dist/review.d.ts CHANGED
@@ -1,50 +1,37 @@
1
+ import type { Finding, FindingFix, FindingReport, FindingSeverity, ValidateOptions, ValidationCategory, ValidationReport } from "@openpresentation/opf";
1
2
  import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
3
 
3
- export type ReviewSeverity = "error" | "warning" | "info";
4
+ export type ReviewSeverity = FindingSeverity;
5
+ /** A suggested repair from core's `validate` or a hosted reviewer: core's `FindingFix` (`title`, `patch` of RFC 6902 operations, or a `focus` field). */
6
+ export type ReviewFix = FindingFix;
4
7
 
5
- /** A suggested repair from core's audit (the structural subset the editor uses). */
6
- export interface ReviewFix {
8
+ /** A finding of core's `validate` or of a hosted reviewer (core's `Finding`), with the fields the editor derives. */
9
+ export interface ReviewFinding extends Omit<Finding, "slide"> {
10
+ /** Unique within the report: the source, the rule id and the path, numbered when they repeat. */
7
11
  id: string;
8
- label: string;
9
- kind: "patch" | "focus";
10
- /** True when the change cannot alter what the content says. */
11
- safe: boolean;
12
- patch?: JsonPatchOperation[];
13
- focus?: { path: string; field: "alt" | "title" | "text" | "link" | "language" | "fontSize"; value?: unknown };
14
- }
15
-
16
- /** One finding of core's audit, with the fields the editor adds. */
17
- export interface ReviewFinding {
18
- /** Unique within the report: the rule id and the path, numbered when they repeat. */
19
- id: string;
20
- ruleId: string;
21
- severity: ReviewSeverity;
22
- /** JSON Pointer of the content the finding is about. */
23
- path: string;
24
12
  /** The same path in OPF dotted form (`slides.0.text.1.color`). */
25
13
  dottedPath: string;
26
14
  /** Zero-based slide index, or null for a finding about the whole presentation. */
27
15
  slide: number | null;
28
- message: string;
29
- help: string;
30
- category?: "accessibility" | "design" | "content";
31
- measured?: Record<string, number | string | boolean | null>;
32
- fixes?: ReviewFix[];
33
16
  }
34
17
 
35
- export interface ReviewReport {
36
- valid: boolean;
37
- documentValid: boolean;
38
- diagnostics: Omit<ReviewFinding, "id" | "dottedPath">[];
39
- counts: Record<ReviewSeverity, number>;
40
- [key: string]: unknown;
18
+ /** What the Review panel holds: core's `FindingReport` of every source merged, the editor's findings, and the sources present (`opf`, `pptx.dev/review`, ...). */
19
+ export interface ReviewReport extends FindingReport {
20
+ findings: ReviewFinding[];
21
+ /** The `Finding.source` of every group of findings in the report. `opf` is core's own checks. */
22
+ sources: string[];
41
23
  }
42
24
 
43
- /** Core's `AuditOptions` (severity per rule, `ignore`, `only`, `thresholds`, `textMeasurement`, ...). */
44
- export type ReviewAuditOptions = Record<string, unknown> & {
45
- /** Use this audit function instead of core's `auditPresentation`. */
46
- audit?: (document: unknown, options?: Record<string, unknown>) => ReviewReport;
47
- };
25
+ /** The hook that brings a hosted reviewer's findings into the panel (see `createReviewPanel`). */
26
+ export type ReviewHook = (presentation: unknown, options: ReviewHookOptions) => Promise<FindingReport>;
27
+ export interface ReviewHookOptions {
28
+ /** Aborted when the document changes while the review runs, or the panel is destroyed. */
29
+ signal: AbortSignal;
30
+ /** Core's report for this presentation, so a hosted reviewer need not repeat the deterministic checks. */
31
+ report: ValidationReport | undefined;
32
+ /** The options core's `validate` ran with (from the panel's `getValidateOptions`). */
33
+ validateOptions: ValidateOptions;
34
+ }
48
35
 
49
36
  export interface ReviewTarget {
50
37
  /** The slide the finding is on, or null for the presentation. */
@@ -57,19 +44,25 @@ export interface ReviewTarget {
57
44
  }
58
45
 
59
46
  export declare const reviewSeverities: readonly ReviewSeverity[];
60
- /** True when the installed core ships the audit (`@openpresentation/opf` after 0.11.4). */
61
- export declare function auditAvailable(): boolean;
62
- /** Audit a document with core's `auditPresentation`. Throws `audit-unavailable` when core has none. */
63
- export declare function runAudit(document: unknown, options?: ReviewAuditOptions): ReviewReport;
64
- export declare function reviewFindings(report: ReviewReport | undefined): ReviewFinding[];
47
+ /** Core's finding categories in report order: format, references, policy, accessibility, layout, content. */
48
+ export declare const reviewCategories: readonly ValidationCategory[];
49
+ /** The `source` of a finding that names none: core's own checks. */
50
+ export declare const CORE_SOURCE: "opf";
51
+ /** Merge core's report and a hosted reviewer's, keeping findings per `Finding.source` (a report replaces earlier findings of the sources it carries). A hook finding with no source gets `hookSource` (default `review`). */
52
+ export declare function mergeFindingReports(core: FindingReport | undefined, hook?: FindingReport | undefined, options?: { hookSource?: string }): FindingReport & { sources: string[] };
53
+ export declare function reviewFindings(report: Pick<FindingReport, "findings"> | undefined): ReviewFinding[];
65
54
  export declare function countFindings(findings: readonly ReviewFinding[]): { error: number; warning: number; info: number; total: number };
66
55
  export declare function filterFindings(findings: readonly ReviewFinding[], options?: { minimum?: ReviewSeverity; slide?: number | null }): ReviewFinding[];
56
+ /** The deck first, then each slide; errors before warnings before notes; then by rule and path. */
57
+ export declare function sortFindings(findings: readonly ReviewFinding[]): ReviewFinding[];
58
+ /** Findings grouped by category: core's categories in core's order, then any other category in the order it first appears. Empty categories are left out. */
59
+ export declare function groupFindings(findings: readonly ReviewFinding[]): { category: string; findings: ReviewFinding[] }[];
67
60
  /** Where "go to" lands for a finding. */
68
- export declare function findingTarget(document: unknown, finding: Pick<ReviewFinding, "path">): ReviewTarget;
61
+ export declare function findingTarget(presentation: unknown, finding: Pick<ReviewFinding, "path">): ReviewTarget;
69
62
  /** Apply a `patch` fix as one undoable, validated session change; refuses a stale or unsafe patch. */
70
63
  export declare function applyReviewFix(editor: EditorSession, finding: Pick<ReviewFinding, "ruleId"> | undefined, fix: ReviewFix, meta?: Record<string, unknown>): EditorChange;
71
64
  /** The patch that sets (or, for `""`, marks decorative) the alt text of the picture at `pointer`; null when nothing changes. */
72
- export declare function altTextPatch(document: unknown, pointer: string, text: string): JsonPatchOperation[] | null;
65
+ export declare function altTextPatch(presentation: unknown, pointer: string, text: string): JsonPatchOperation[] | null;
73
66
  export declare function setReviewAltText(editor: EditorSession, pointer: string, text: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
74
67
  export declare function markDecorative(editor: EditorSession, pointer: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
75
- export declare function currentAltText(document: unknown, pointer: string): string;
68
+ export declare function currentAltText(presentation: unknown, pointer: string): string;
package/dist/review.js CHANGED
@@ -1,17 +1,14 @@
1
- // Review (RR-29): the headless model behind the Review panel. It runs core's design and accessibility audit
2
- // (`auditPresentation`) over the session's document, turns a finding into something an editor can act on (the
3
- // slide and the nearest existing path to select) and applies its suggested fixes through the session, so every
4
- // fix is a validated, undoable JSON Patch edit like any other. Importing this module needs no DOM.
5
- // Core is read from the namespace so an older core still loads this module; the audit then throws "audit-unavailable".
6
- import * as core from "@openpresentation/opf";
1
+ // Review (RR-29, RR-55): the headless model behind the Review panel. The findings are core's: `validate(presentation)` runs the
2
+ // full check (format, references, policy, accessibility, layout and content) and returns `Finding`s with a rule id, severity,
3
+ // category, JSON Pointer path and optional `fixes`. A hosted reviewer (pptx.dev's AI review, a house style checker) returns the
4
+ // same shape through the panel's `review` hook, and `mergeFindingReports` puts both in one list keyed by `Finding.source`. This
5
+ // module turns a finding into something an editor can act on (the slide and the nearest existing path to select) and applies its
6
+ // suggested fixes through the session, so every fix is a validated, undoable JSON Patch edit like any other. Importing this
7
+ // module needs no DOM.
8
+ import { validationCategories } from "@openpresentation/opf";
7
9
  import { OPFEditorError, getValueAtPath, hasValueAtPath, jsonPointerToOpfPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
8
10
  import { assetIdOf, prepareAssetAlt } from "./assets.js";
9
11
 
10
- /** True when the installed core ships the audit (`@openpresentation/opf` after 0.11.4). */
11
- export function auditAvailable() {
12
- return typeof core.auditPresentation === "function";
13
- }
14
-
15
12
  function fail(code, message, details) {
16
13
  return new OPFEditorError(code, message, details);
17
14
  }
@@ -19,34 +16,59 @@ function fail(code, message, details) {
19
16
  const SEVERITIES = ["error", "warning", "info"];
20
17
  const RANK = { error: 3, warning: 2, info: 1 };
21
18
  export const reviewSeverities = Object.freeze([...SEVERITIES]);
19
+ /** Core's finding categories in the order a report lists them: format, references, policy, accessibility, layout, content. */
20
+ export const reviewCategories = validationCategories;
21
+ /** The `source` of a finding that names none: core's own checks (`Finding.source` defaults to `opf`). */
22
+ export const CORE_SOURCE = "opf";
23
+
24
+ const sourceOf = (finding) => finding?.source ?? CORE_SOURCE;
25
+
26
+ const countBySeverity = (findings) => {
27
+ const counts = { error: 0, warning: 0, info: 0 };
28
+ for (const finding of findings) if (finding.severity in counts) counts[finding.severity] += 1;
29
+ return counts;
30
+ };
22
31
 
23
32
  /**
24
- * Audit a document. `options` are core's `AuditOptions` (severity per rule, `ignore`, thresholds, `textMeasurement`, ...);
25
- * pass `options.audit` to use another audit function. Returns core's report.
33
+ * One report from core's and a hosted reviewer's. Findings are kept per `Finding.source`: a report replaces what an earlier one
34
+ * said for the sources it carries, so the hook's findings (`pptx.dev/review`) sit beside core's (`opf`) and never overwrite them.
35
+ * A hook finding that names no source gets `hookSource` (default `review`), so it cannot be mistaken for one of core's.
36
+ * Returns `{ valid, findings, counts, sources }` (core's `FindingReport` plus the sources present).
26
37
  */
27
- export function runAudit(document, options = {}) {
28
- const { audit = core.auditPresentation, ...auditOptions } = options;
29
- if (typeof audit !== "function") {
30
- throw fail("audit-unavailable", "The review needs a core release that ships the audit (@openpresentation/opf after 0.11.4).");
31
- }
32
- return audit(document, auditOptions);
38
+ export function mergeFindingReports(core, hook, { hookSource = "review" } = {}) {
39
+ const bySource = new Map();
40
+ const add = (report, defaultSource) => {
41
+ const fresh = new Map();
42
+ for (const finding of report?.findings ?? []) {
43
+ const source = finding.source ?? defaultSource;
44
+ const entry = source === finding.source ? finding : { ...finding, source };
45
+ if (!fresh.has(source)) fresh.set(source, []);
46
+ fresh.get(source).push(entry);
47
+ }
48
+ for (const [source, list] of fresh) bySource.set(source, list);
49
+ };
50
+ add(core, CORE_SOURCE);
51
+ add(hook, hookSource);
52
+ const findings = [...bySource.values()].flat();
53
+ const counts = countBySeverity(findings);
54
+ return { valid: counts.error === 0, findings, counts, sources: [...bySource.keys()] };
33
55
  }
34
56
 
35
57
  /**
36
- * The review's findings: core's diagnostics with an `id` unique within the report, the slide index (or null for
37
- * the deck) and `dottedPath`, the OPF path to select. Sorted by slide, then severity (errors first), then rule.
58
+ * The review's findings: a report's `findings` with an `id` unique within the report, the slide index (or null for
59
+ * the deck) and `dottedPath`, the OPF path to select.
38
60
  */
39
61
  export function reviewFindings(report) {
40
62
  const seen = new Map();
41
- return (report?.diagnostics ?? []).map((diagnostic) => {
42
- const base = `${diagnostic.ruleId}|${diagnostic.path}`;
63
+ return (report?.findings ?? []).map((finding) => {
64
+ const base = `${sourceOf(finding)}|${finding.ruleId}|${finding.path}`;
43
65
  const count = seen.get(base) ?? 0;
44
66
  seen.set(base, count + 1);
45
67
  return {
46
- ...diagnostic,
68
+ ...finding,
47
69
  id: count ? `${base}#${count}` : base,
48
- slide: Number.isInteger(diagnostic.slide) ? diagnostic.slide : slideOfPointer(diagnostic.path),
49
- dottedPath: safeDotted(diagnostic.path),
70
+ slide: Number.isInteger(finding.slide) ? finding.slide : slideOfPointer(finding.path),
71
+ dottedPath: safeDotted(finding.path),
50
72
  };
51
73
  });
52
74
  }
@@ -65,9 +87,7 @@ function safeDotted(pointer) {
65
87
 
66
88
  /** Counts by severity and the number of findings. */
67
89
  export function countFindings(findings) {
68
- const counts = { error: 0, warning: 0, info: 0 };
69
- for (const finding of findings) counts[finding.severity] += 1;
70
- return { ...counts, total: findings.length };
90
+ return { ...countBySeverity(findings), total: findings.length };
71
91
  }
72
92
 
73
93
  /** Findings at or above `minimum` severity (`info` keeps all), optionally only those of one slide (or the deck, `null`). */
@@ -75,61 +95,80 @@ export function filterFindings(findings, { minimum = "info", slide } = {}) {
75
95
  return findings.filter((finding) => RANK[finding.severity] >= RANK[minimum] && (slide === undefined || finding.slide === slide));
76
96
  }
77
97
 
98
+ /** Findings in reading order: the deck first, then each slide; errors before warnings before notes; then by rule and path. */
99
+ export function sortFindings(findings) {
100
+ return [...findings].sort((a, b) =>
101
+ (a.slide ?? -1) - (b.slide ?? -1) || RANK[b.severity] - RANK[a.severity] || a.ruleId.localeCompare(b.ruleId) || a.path.localeCompare(b.path));
102
+ }
103
+
104
+ /**
105
+ * Findings grouped by category, core's categories first in core's order, then any other category (a hosted reviewer's `narrative`,
106
+ * ...) in the order it first appears. Each group is sorted as `sortFindings` does. Empty categories are left out.
107
+ */
108
+ export function groupFindings(findings) {
109
+ const groups = new Map(reviewCategories.map((category) => [category, []]));
110
+ for (const finding of findings) {
111
+ if (!groups.has(finding.category)) groups.set(finding.category, []);
112
+ groups.get(finding.category).push(finding);
113
+ }
114
+ return [...groups].filter(([, list]) => list.length).map(([category, list]) => ({ category, findings: sortFindings(list) }));
115
+ }
116
+
78
117
  /**
79
118
  * Where "go to" lands for a finding: the slide and the deepest existing path on the finding's way (a finding about
80
119
  * a missing field points at the field's parent). `path` is the OPF dotted path a host selects; `pointer` the JSON Pointer.
81
120
  */
82
- export function findingTarget(document, finding) {
121
+ export function findingTarget(presentation, finding) {
83
122
  const segments = splitOpfPath(finding.path ?? "");
84
123
  let length = segments.length;
85
- while (length > 0 && !hasValueAtPath(document, segments.slice(0, length))) length -= 1;
124
+ while (length > 0 && !hasValueAtPath(presentation, segments.slice(0, length))) length -= 1;
86
125
  const kept = segments.slice(0, length);
87
126
  const slide = kept[0] === "slides" && /^\d+$/.test(kept[1] ?? "") ? Number(kept[1]) : null;
88
127
  return { slide, path: kept.join("."), pointer: opfPathToJsonPointer(kept), exact: length === segments.length };
89
128
  }
90
129
 
91
- const PATCH_OPS = new Set(["add", "replace", "remove"]);
92
- const MAX_FIX_OPERATIONS = 4;
130
+ const PATCH_OPS = new Set(["add", "replace", "remove", "move", "copy", "test"]);
131
+ const MAX_FIX_OPERATIONS = 20;
132
+ const STALE = "The slide has changed since this finding was made. The review has been refreshed; apply the fix again if it still shows.";
93
133
 
94
- function checkPatch(document, operations) {
134
+ function checkPatch(presentation, operations) {
95
135
  if (!Array.isArray(operations) || !operations.length || operations.length > MAX_FIX_OPERATIONS) {
96
136
  throw fail("invalid-fix", `A review fix changes one to ${MAX_FIX_OPERATIONS} fields.`);
97
137
  }
98
138
  for (const operation of operations) {
99
139
  if (!operation || !PATCH_OPS.has(operation.op) || typeof operation.path !== "string" || !operation.path.startsWith("/") || operation.path === "/") {
100
- throw fail("invalid-fix", "A review fix may add, replace or remove a field below the document root.", { operation });
140
+ throw fail("invalid-fix", "A review fix may change a field below the document root.", { operation });
101
141
  }
102
142
  // The finding is stale when the field it was about has since changed: refuse instead of guessing.
103
- if ((operation.op === "replace" || operation.op === "remove") && !hasValueAtPath(document, operation.path)) {
104
- throw fail("stale-finding", "The slide has changed since this finding was made. The review has been refreshed; apply the fix again if it still shows.", { operation });
105
- }
143
+ if ((operation.op === "replace" || operation.op === "remove") && !hasValueAtPath(presentation, operation.path)) throw fail("stale-finding", STALE, { operation });
144
+ if ((operation.op === "move" || operation.op === "copy") && (typeof operation.from !== "string" || !hasValueAtPath(presentation, operation.from))) throw fail("stale-finding", STALE, { operation });
106
145
  }
107
146
  }
108
147
 
109
148
  /**
110
- * Apply one `patch` fix of a finding as a single undoable session change (validated: a fix that would make the
111
- * document invalid is rejected). Returns the session's change.
149
+ * Apply one `patch` fix of a finding (core's `FindingFix`: `title`, `patch`; a fix with no `kind` is a patch fix) as a single
150
+ * undoable session change, validated: a fix that would make the document invalid is rejected. Returns the session's change.
112
151
  */
113
152
  export function applyReviewFix(editor, finding, fix, meta = {}) {
114
153
  if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
115
- if (!fix || fix.kind !== "patch") throw fail("invalid-fix", "Only patch fixes can be applied; a focus fix asks the author to type something.");
116
- checkPatch(editor.document, fix.patch);
154
+ if (!fix || (fix.kind ?? "patch") !== "patch") throw fail("invalid-fix", "Only patch fixes can be applied; a focus fix asks the author to type something.");
155
+ checkPatch(editor.presentation, fix.patch);
117
156
  return editor.applyPatch(fix.patch, { ...meta, source: meta.source ?? "review", ruleId: finding?.ruleId, fix: fix.id, rejectInvalid: true });
118
157
  }
119
158
 
120
159
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
121
160
 
122
161
  /** The patch operations that give the asset value at `pointer` alt text (`""` marks it decorative), or `null` when it already has exactly this alt. */
123
- export function altTextPatch(document, pointer, text) {
124
- const value = getValueAtPath(document, pointer);
162
+ export function altTextPatch(presentation, pointer, text) {
163
+ const value = getValueAtPath(presentation, pointer);
125
164
  if (value === undefined) throw fail("stale-finding", "The picture this finding is about no longer exists.", { pointer });
126
165
  const alt = String(text ?? "");
127
166
  const id = assetIdOf(value);
128
- const entry = id === undefined ? undefined : getValueAtPath(document, ["assets", id]);
167
+ const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
129
168
  // A picture that points into the assets registry gets its alt text there, so every use of the asset has it.
130
169
  if (id !== undefined && entry !== undefined) {
131
170
  // prepareAssetAlt treats "" as "remove the alt"; an empty alt here is the decorative choice and must stay in the document.
132
- if (alt !== "") return prepareAssetAlt(document, id, alt).patches;
171
+ if (alt !== "") return prepareAssetAlt(presentation, id, alt).patches;
133
172
  if (isObject(entry)) {
134
173
  if (entry.alt === "") return null;
135
174
  return [{ op: Object.hasOwn(entry, "alt") ? "replace" : "add", path: opfPathToJsonPointer(["assets", id, "alt"]), value: "" }];
@@ -148,23 +187,23 @@ export function altTextPatch(document, pointer, text) {
148
187
  export function setReviewAltText(editor, pointer, text, meta = {}) {
149
188
  const alt = String(text ?? "").trim();
150
189
  if (!alt) throw fail("empty-alt-text", "Write what the picture shows, or mark it decorative.", { pointer });
151
- const patch = altTextPatch(editor.document, pointer, alt);
152
- if (!patch) return { changed: false, document: editor.document };
190
+ const patch = altTextPatch(editor.presentation, pointer, alt);
191
+ if (!patch) return { changed: false, presentation: editor.presentation };
153
192
  return { changed: true, ...editor.applyPatch(patch, { ...meta, source: meta.source ?? "review-alt", pointer, rejectInvalid: true }) };
154
193
  }
155
194
 
156
195
  /** Mark a picture decorative (empty alt text), an explicit choice, as one undoable change. */
157
196
  export function markDecorative(editor, pointer, meta = {}) {
158
- const patch = altTextPatch(editor.document, pointer, "");
159
- if (!patch) return { changed: false, document: editor.document };
197
+ const patch = altTextPatch(editor.presentation, pointer, "");
198
+ if (!patch) return { changed: false, presentation: editor.presentation };
160
199
  return { changed: true, ...editor.applyPatch(patch, { ...meta, source: meta.source ?? "review-decorative", pointer, rejectInvalid: true }) };
161
200
  }
162
201
 
163
202
  /** The alt text a picture has now (own or through the assets registry), for pre-filling the field. */
164
- export function currentAltText(document, pointer) {
165
- const value = getValueAtPath(document, pointer);
203
+ export function currentAltText(presentation, pointer) {
204
+ const value = getValueAtPath(presentation, pointer);
166
205
  if (isObject(value) && typeof value.alt === "string") return value.alt;
167
206
  const id = assetIdOf(value);
168
- const entry = id === undefined ? undefined : getValueAtPath(document, ["assets", id]);
207
+ const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
169
208
  return isObject(entry) && typeof entry.alt === "string" ? entry.alt : "";
170
209
  }
@@ -27,14 +27,15 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
27
27
  const range=doc.createRange();range.setStart(node.firstChild,start);range.setEnd(node.firstChild,end);return range.getBoundingClientRect();
28
28
  }
29
29
  function boundaries() {
30
- const result=[];
30
+ // A rich quote draws its quotation marks inside its first and last run (FA-10): they sit just outside the text, so their offsets clamp to its ends.
31
+ const result=[],total=richTextContent(current).length,clampOffset=offset=>Math.max(0,Math.min(total,offset));
31
32
  for(const node of fragments()) {
32
33
  if(!node.firstChild)continue;
33
34
  const start=Number(node.dataset.opfTextStart),text=node.textContent;
34
35
  for(const part of new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(text)) {
35
36
  const box=rect(node,part.index,part.index+part.segment.length),rtl=win.getComputedStyle(node).direction==='rtl';
36
- result.push({offset:start+part.index,x:rtl?box.right:box.left,y:box.top,height:box.height,node});
37
- result.push({offset:start+part.index+part.segment.length,x:rtl?box.left:box.right,y:box.top,height:box.height,node});
37
+ result.push({offset:clampOffset(start+part.index),x:rtl?box.right:box.left,y:box.top,height:box.height,node});
38
+ result.push({offset:clampOffset(start+part.index+part.segment.length),x:rtl?box.left:box.right,y:box.top,height:box.height,node});
38
39
  }
39
40
  }
40
41
  const target=getTarget(path),matrix=target?.getScreenCTM();
@@ -72,7 +72,7 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
72
72
  onChange?.(snapshot.path);
73
73
  } finally {restoring=false;}
74
74
  }
75
- for(const [key,label] of [['bold','Bold'],['italic','Italic'],['underline','Underline'],['strikethrough','Strike'],['superscript','Superscript'],['subscript','Subscript']])
75
+ for(const [key,label] of [['bold','Bold'],['italic','Italic'],['underline','Underline'],['strikethrough','Strike'],['superscript','Superscript'],['subscript','Subscript'],['code','Code']])
76
76
  buttons.set(key,button(label,()=>apply({[key]:!currentRuns().every(run=>!!run[key])})));
77
77
  function field(label,type='text') {
78
78
  const input=doc.createElement('input');input.type=type;input.setAttribute('aria-label',label);input.placeholder=label;
@@ -85,11 +85,13 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
85
85
  const size=field('Font size (pt)','number');size.min='0.1';size.step='0.5';size.style.width='80px';
86
86
  size.onchange=()=>{try{apply({fontSize:size.value?Number(size.value):null});}catch(error){report(error);}};
87
87
  const family=field('Font family');family.onchange=()=>{try{apply({fontFamily:family.value||null});}catch(error){report(error);}};
88
- const link=field('Link URL');link.onchange=()=>{try{if(link.value&&!/^(https?:|mailto:)/i.test(link.value))throw new Error('Use an HTTP, HTTPS, or mailto link.');apply({link:link.value||null});}catch(error){report(error);}};
88
+ const link=field('Link URL');link.onchange=()=>{try{if(link.value&&!/^(https?:\/\/|mailto:|tel:)\S+$/.test(link.value))throw new Error('Use an HTTP, HTTPS, mailto or tel link without spaces.');apply({link:link.value||null});}catch(error){report(error);}};
89
+ const language=field('Language tag');language.style.width='80px';
90
+ language.onchange=()=>{try{if(language.value&&!/^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(language.value))throw new Error('Use a BCP-47 language tag such as fr-FR or ja-JP.');apply({lang:language.value||null});}catch(error){report(error);}};
89
91
  const replacement=field('Selected text');replacement.style.width='160px';
90
92
  replacement.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();try{apply(null,replacement.value);}catch(error){report(error);}}};
91
93
  button('Replace text',()=>apply(null,replacement.value));
92
- button('Reset style',()=>apply(Object.fromEntries(['bold','italic','underline','strikethrough','superscript','subscript','color','fontSize','fontFamily','link'].map(key=>[key,null]))));
94
+ button('Reset style',()=>apply(Object.fromEntries(['bold','italic','underline','strikethrough','superscript','subscript','code','color','fontSize','fontFamily','link','lang'].map(key=>[key,null]))));
93
95
  button('Edit text',()=>{const path=selected?.path;hide();if(path)onTyping?.(path);});
94
96
  button('Edit runs',()=>{const path=selected?.path;hide();if(path)onProperties?.(path);});
95
97
  button('Done',()=>{hide();win.getSelection()?.removeAllRanges();});
package/dist/rich-text.js CHANGED
@@ -19,8 +19,10 @@ function range(value, start, end, allowEmpty) {
19
19
  if (!boundaries.has(start) || !boundaries.has(end)) throw new RangeError('Text ranges must end at whole grapheme boundaries.');
20
20
  return runs(value);
21
21
  }
22
- const booleans = ['bold','italic','underline','strikethrough','superscript','subscript'];
23
- const strings = ['color','fontFamily','link'];
22
+ const booleans = ['bold','italic','underline','strikethrough','superscript','subscript','code'];
23
+ const strings = ['color','fontFamily','link','lang'];
24
+ // TextRun.lang is a BCP-47 tag (core schema pattern).
25
+ const languageTag = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/;
24
26
  function checkedFormat(format) {
25
27
  if (!format || typeof format !== 'object' || Array.isArray(format)) throw new TypeError('Expected a run formatting object.');
26
28
  for (const [key,value] of Object.entries(format)) {
@@ -29,6 +31,7 @@ function checkedFormat(format) {
29
31
  if (booleans.includes(key) && typeof value !== 'boolean' || strings.includes(key) && typeof value !== 'string' || key === 'fontSize' && (!Number.isFinite(value) || value <= 0))
30
32
  throw new TypeError(`Invalid run style: ${key}`);
31
33
  }
34
+ if (typeof format.lang === 'string' && !languageTag.test(format.lang)) throw new TypeError('Use a BCP-47 language tag such as fr-FR or ja-JP.');
32
35
  if (format.superscript && format.subscript) throw new TypeError('Choose superscript or subscript, not both.');
33
36
  return {...format, ...(format.superscript ? {subscript:false} : {}), ...(format.subscript ? {superscript:false} : {})};
34
37
  }
@@ -1,3 +1,3 @@
1
1
  import type { EditorSession } from './index.js';
2
- export interface SchemaInspector { navigate(path:string|string[]):void; commit():boolean; reset():void; readonly document:any; readonly dirty:boolean; destroy():void; }
3
- export declare function createSchemaInspector(container:HTMLElement, options:{editor:EditorSession;path?:string;onDraft?:(event:{document:any;path:string;dirty:boolean})=>void;onCommit?:(event:{editor:EditorSession;path:string})=>void;onCancel?:()=>void;onError?:(error:Error)=>void}):SchemaInspector;
2
+ export interface SchemaInspector { navigate(path:string|string[]):void; commit():boolean; reset():void; readonly presentation:any; readonly dirty:boolean; destroy():void; }
3
+ export declare function createSchemaInspector(container:HTMLElement, options:{editor:EditorSession;path?:string;onDraft?:(event:{presentation:any;path:string;dirty:boolean})=>void;onCommit?:(event:{editor:EditorSession;path:string})=>void;onCancel?:()=>void;onError?:(error:Error)=>void}):SchemaInspector;
@@ -1,9 +1,11 @@
1
- import { applyJsonPatch, createValuePatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from './index.js';
1
+ import { applyJsonPatch, createValuePatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from './index.js';
2
2
  import { schemaAtPath, schemaVariants, activeSchema, schemaType, schemaLabel, createSchemaValue } from './schema.js';
3
+ import { validate } from "@openpresentation/opf";
4
+ import { checkFormat } from "./checks.js";
3
5
  /** A staged form editor; valid drafts preview immediately and Apply creates one undo step. */
4
6
  export function createSchemaInspector(container, {editor,path='',onDraft,onCommit,onCancel,onError}={}) {
5
7
  if(!editor)throw new Error('An editor session is required.');
6
- let base=JSON.stringify(editor.document), draft=structuredClone(editor.document), selected=opfPathToJsonPointer(path), dirty=false, destroyed=false, suppress=false;
8
+ let base=JSON.stringify(editor.presentation), draft=structuredClone(editor.presentation), selected=opfPathToJsonPointer(path), dirty=false, destroyed=false, suppress=false;
7
9
  const doc=container.ownerDocument;
8
10
  const el=(tag,text,cls)=>{const node=doc.createElement(tag);if(text!==undefined)node.textContent=text;if(cls)node.className=cls;return node;};
9
11
  const button=(text,action)=>{const node=el('button',text);node.type='button';node.onclick=()=>{try{action();}catch(error){showError(error.message);onError?.(error);}};return node;};
@@ -15,24 +17,27 @@ export function createSchemaInspector(container, {editor,path='',onDraft,onCommi
15
17
  container.replaceChildren(nav,body,errorBox,actions);container.classList.add('opf-schema-inspector');
16
18
  function showError(message){errorBox.textContent=message;}
17
19
  function check(){
18
- const validation=validateOpfDocument(draft);
20
+ // The draft must be valid OPF (the format category, as every edit); unresolved references (an unknown variable, a missing asset) are listed as notes.
21
+ const report=validate(draft,{only:['format','references']});
22
+ const errors=report.findings.filter(finding=>finding.severity==='error'&&finding.category==='format'),warnings=report.findings.filter(finding=>finding.category!=='format'&&finding.severity!=='info');
23
+ const validation={valid:!errors.length};
19
24
  apply.disabled=!dirty||!validation.valid;
20
- const warningNote=validation.warnings?.length?` · ${validation.warnings.length} warning${validation.warnings.length===1?'':'s'}`:'';
25
+ const warningNote=warnings.length?` · ${warnings.length} warning${warnings.length===1?'':'s'}`:'';
21
26
  status.textContent=dirty?`Draft changes · apply to save${warningNote}`:`No pending changes${warningNote}`;
22
- const issues=[...(validation.valid?[]:validation.errors.slice(0,3).map(error=>`${error.path||'/'}: ${error.message}`)),...(validation.warnings?.slice(0,2).map(warning=>`${warning.path||'/'}: ${warning.message}`)??[])];
27
+ const issues=[...(validation.valid?[]:errors.slice(0,3).map(error=>`${error.path||'/'}: ${error.message}`)),...warnings.slice(0,2).map(warning=>`${warning.path||'/'}: ${warning.message}`)];
23
28
  showError(issues.join('\n'));
24
- if(validation.valid)try{onDraft?.({document:structuredClone(draft),path:selected,dirty});}catch(error){showError(`Preview: ${error.message}`);onError?.(error);}
29
+ if(validation.valid)try{onDraft?.({presentation:structuredClone(draft),path:selected,dirty});}catch(error){showError(`Preview: ${error.message}`);onError?.(error);}
25
30
  }
26
31
  function mutate(operations,redraw=true){draft=applyJsonPatch(draft,operations);dirty=JSON.stringify(draft)!==base;if(redraw)render();check();}
27
32
  function set(path,value,redraw=true){mutate(createValuePatch(draft,path,value),redraw);}
28
33
  function navigate(path){selected=opfPathToJsonPointer(path);render();check();}
29
- function reset(){base=JSON.stringify(editor.document);draft=structuredClone(editor.document);dirty=false;render();check();onCancel?.();}
34
+ function reset(){base=JSON.stringify(editor.presentation);draft=structuredClone(editor.presentation);dirty=false;render();check();onCancel?.();}
30
35
  function commit(){
31
36
  if(!dirty)return true;
32
- if(JSON.stringify(editor.document)!==base){showError('The document changed elsewhere. Discard this draft and reopen the field to avoid overwriting newer changes.');return false;}
33
- const result=validateOpfDocument(draft);if(!result.valid){check();return false;}
37
+ if(JSON.stringify(editor.presentation)!==base){showError('The document changed elsewhere. Discard this draft and reopen the field to avoid overwriting newer changes.');return false;}
38
+ const result=checkFormat(draft);if(!result.valid){check();return false;}
34
39
  suppress=true;
35
- try{editor.applyPatch([{op:'replace',path:'',value:draft}],{source:'schema-inspector',rejectInvalid:true});base=JSON.stringify(editor.document);draft=structuredClone(editor.document);dirty=false;render();check();onCommit?.({editor,path:selected});return true;}finally{suppress=false;}
40
+ try{editor.applyPatch([{op:'replace',path:'',value:draft}],{source:'schema-inspector',rejectInvalid:true});base=JSON.stringify(editor.presentation);draft=structuredClone(editor.presentation);dirty=false;render();check();onCommit?.({editor,path:selected});return true;}finally{suppress=false;}
36
41
  }
37
42
  function summary(value){return Array.isArray(value)?`${value.length} items`:value&&typeof value==='object'?`${Object.keys(value).length} fields`:String(value??'').slice(0,80);}
38
43
  function render(){
@@ -98,5 +103,5 @@ export function createSchemaInspector(container, {editor,path='',onDraft,onCommi
98
103
  }
99
104
  const unsubscribe=editor.subscribe(()=>{if(suppress)return;if(dirty){showError('The document changed elsewhere. Discard the draft to load the latest version.');apply.disabled=true;}else reset();});
100
105
  render();check();
101
- return {navigate,commit,reset,get document(){return structuredClone(draft);},get dirty(){return dirty;},destroy(){destroyed=true;unsubscribe();container.replaceChildren();}};
106
+ return {navigate,commit,reset,get presentation(){return structuredClone(draft);},get dirty(){return dirty;},destroy(){destroyed=true;unsubscribe();container.replaceChildren();}};
102
107
  }
package/dist/schema.d.ts CHANGED
@@ -4,7 +4,7 @@ export declare function resolveSchema(schema: Schema, root?: Schema): Schema;
4
4
  export declare function schemaVariants(schema: Schema, root?: Schema): Schema[];
5
5
  export declare function schemaType(schema: Schema, value?: unknown): string;
6
6
  export declare function activeSchema(schema: Schema, value: unknown, root?: Schema): Schema;
7
- export declare function schemaAtPath(document: unknown, path: string | string[], root?: Schema): Schema;
7
+ export declare function schemaAtPath(presentation: unknown, path: string | string[], root?: Schema): Schema;
8
8
  export declare function schemaLabel(schema: Schema, index?: number): string;
9
9
  export declare function createSchemaValue(schema: Schema, root?: Schema): any;
10
10
  export declare function listSchemaFields(): Array<{schema:string;path:string;name:string;description:string;reference:string|null;type:string|null;values:unknown[]|null}>;
package/dist/schema.js CHANGED
@@ -3,9 +3,22 @@ import { getValueAtPath, splitOpfPath, opfPathToJsonPointer } from './index.js';
3
3
  export const opfSchemas = schemas;
4
4
  const documents = new Map(Object.values(schemas).map(schema => [schema.$id, schema]));
5
5
  const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
6
+ // An allOf of shaping members (the SlideDesign definition is Design plus a `not` constraint) reads as one schema: members are
7
+ // merged, properties included; constraint-only members (`not`, `required`) shape nothing a picker can show and are dropped.
8
+ function mergeAllOf(schema, root, seen) {
9
+ if (!Array.isArray(schema.allOf)) return schema;
10
+ const {allOf, ...base} = schema;
11
+ let merged = base;
12
+ for (const member of allOf) {
13
+ const resolved = resolveSchema(member, root, new Set(seen));
14
+ if (!resolved.properties && !resolved.type && !resolved.oneOf && !resolved.anyOf) continue;
15
+ merged = {...resolved, ...merged, ...(resolved.properties || merged.properties ? {properties: {...resolved.properties, ...merged.properties}} : {})};
16
+ }
17
+ return merged;
18
+ }
6
19
  export function resolveSchema(schema, root = schemas.presentation, seen = new Set()) {
7
20
  if (!schema || typeof schema !== 'object') return {};
8
- if (!schema.$ref) return schema;
21
+ if (!schema.$ref) return mergeAllOf(schema, root, seen);
9
22
  const ref = schema.$ref;
10
23
  if (seen.has(ref)) return {};
11
24
  seen.add(ref);
@@ -15,7 +28,7 @@ export function resolveSchema(schema, root = schemas.presentation, seen = new Se
15
28
  const resolved = fragment ? getValueAtPath(target, fragment) : target;
16
29
  if (!resolved) throw new Error(`Unresolved OPF schema: ${ref}`);
17
30
  const { $ref, ...rest } = schema;
18
- return {...resolveSchema(resolved, target, seen), ...rest};
31
+ return mergeAllOf({...resolveSchema(resolved, target, seen), ...rest}, target, seen);
19
32
  }
20
33
  export function schemaVariants(schema, root = schemas.presentation) {
21
34
  const resolved = resolveSchema(schema, root);
@@ -23,7 +36,14 @@ export function schemaVariants(schema, root = schemas.presentation) {
23
36
  const variants = resolved.oneOf ?? resolved.anyOf;
24
37
  if (!variants) return [resolved];
25
38
  const {$ref, oneOf, anyOf, ...base} = resolved;
26
- return variants.flatMap(child => schemaVariants(child, root).map(variant => ({...base, ...variant, properties: base.properties || variant.properties ? {...base.properties,...variant.properties} : undefined})));
39
+ return variants.flatMap(child => schemaVariants(child, root).map(variant => {
40
+ // A branch that only names required fields (Watermark: exactly one of src and text) adds to the base's required list and is
41
+ // labelled by them, so the generic form offers "src" and "text" as two forms of one object.
42
+ const requiredOnly = !variant.type && !variant.properties && !variant.$ref && Array.isArray(variant.required);
43
+ return {...base, ...variant, properties: base.properties || variant.properties ? {...base.properties,...variant.properties} : undefined,
44
+ ...(base.required || variant.required ? {required: [...new Set([...(base.required ?? []), ...(variant.required ?? [])])]} : {}),
45
+ ...(requiredOnly && !variant.title ? {title: variant.required.join(' + ')} : {})};
46
+ }));
27
47
  }
28
48
  export function schemaType(schema, value) {
29
49
  return schema.type ?? (schema.properties || schema.additionalProperties ? 'object' : schema.items ? 'array' : schema.const !== undefined ? typeof schema.const : value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value === 'undefined' ? 'string' : typeof value);
@@ -45,8 +65,8 @@ function score(schema, value) {
45
65
  export function activeSchema(schema, value, root=schemas.presentation) {
46
66
  return schemaVariants(schema,root).sort((a,b)=>score(b,value)-score(a,value))[0] ?? {};
47
67
  }
48
- export function schemaAtPath(document, path, root = schemas.presentation) {
49
- const parts=splitOpfPath(path);let current=root,value=document;
68
+ export function schemaAtPath(presentation, path, root = schemas.presentation) {
69
+ const parts=splitOpfPath(path);let current=root,value=presentation;
50
70
  for(let index=0; index<parts.length;index++) {
51
71
  const key=parts[index], resolved=activeSchema(current,value,root);
52
72
  if(parts[0]==='catalogs' && parts[2]==='records' && index===3) current=schemas[catalogSchemaNames[parts[1]]]??{};
@@ -12,7 +12,7 @@ export interface SlideManagerOptions {
12
12
  /** Called when the user chooses or moves to a slide; the host shows it and calls `render()`. */
13
13
  setSlideIndex: (index: number) => void;
14
14
  /** The thumbnail of one slide as an HTML string (usually the renderer's SVG). Cache it: it is called for every card on every render. */
15
- renderThumbnail?: (document: unknown, index: number) => string;
15
+ renderThumbnail?: (presentation: unknown, index: number) => string;
16
16
  /** `"navigator"` (a vertical list, the default) or `"sorter"` (a grid with arrow keys in both directions). */
17
17
  variant?: "navigator" | "sorter";
18
18
  /** An element to fill with the Duplicate, Delete, Hide and More buttons. */
@@ -20,7 +20,7 @@ export interface SlideManagerOptions {
20
20
  /** Show Split and Merge in the slide menu. */
21
21
  contentActions?: SlideContentActions;
22
22
  /** Layout choices for "Add slide with layout" (default: the layouts catalog, via `listSwitchOptions`). */
23
- layoutOptions?: (document: unknown) => { id: string; label: string; record?: Record<string, any> }[];
23
+ layoutOptions?: (presentation: unknown) => { id: string; label: string; record?: Record<string, any> }[];
24
24
  /** Every message the manager announces in its live region, for a visual status line. */
25
25
  onStatus?: (message: string) => void;
26
26
  onError?: (error: unknown) => void;