@openpresentation/opf-pptx 0.11.9 → 0.12.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.
@@ -36,10 +36,11 @@ function blipFillIdentity(blipFill) {
36
36
  * left edge and vertically centered. A 90 degree EXIF orientation swaps the
37
37
  * frame's axes, so its visual width is the stored height.
38
38
  */
39
- export function logoFrame(metadata, box) {
39
+ export function logoFrame(metadata, box, anchor = 'left') {
40
40
  const placed = pictureTransform(metadata, box, 'fit');
41
41
  const visualWidth = (metadata.orientation ?? 1) >= 5 ? placed.h : placed.w;
42
- return {...placed, x: placed.x - (box.w - visualWidth) / 2};
42
+ // Right to left (RR-05): a cover logo sits at the top right, anchored to the box's right edge.
43
+ return {...placed, x: placed.x + (anchor === 'right' ? 1 : -1) * (box.w - visualWidth) / 2};
43
44
  }
44
45
 
45
46
  /**
@@ -61,7 +62,7 @@ export function placeLogos(entries, logos, metadataFor, fail) {
61
62
  const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
62
63
  const metadata = metadataFor(part, embed);
63
64
  if (!metadata) fail(logo.path);
64
- const placed = logoFrame(metadata, logo.box);
65
+ const placed = logoFrame(metadata, logo.box, logo.anchor);
65
66
  const attributes = `${placed.rotation ? ` rot="${placed.rotation * 60000}"` : ''}${placed.flipH ? ' flipH="1"' : ''}${placed.flipV ? ' flipV="1"' : ''}`;
66
67
  picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${attributes}><a:off x="${emu(placed.x)}" y="${emu(placed.y)}"/><a:ext cx="${emu(placed.w)}" cy="${emu(placed.h)}"/></a:xfrm>`);
67
68
  picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
@@ -14,7 +14,7 @@ export function metricManifest(value,layout,group) {
14
14
  return {v:1,group,role:'anchor',part:0,line:0,value,parts:layout.parts.map(part=>({role:part.role,visible:part.visible,
15
15
  lines:part.fit?.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))??[]}))};
16
16
  }
17
- export function attachMetricTags(entries,records) {return attachTextTags(entries,records,TAG,'opfMetric','metric');}
17
+ export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions});}
18
18
 
19
19
  function validateManifest(manifest) {
20
20
  if (!validatePresentation({slides:[{metric:manifest.value}]}).valid) throw new Error('Invalid metric source.');
@@ -59,10 +59,13 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
59
59
  const anchors=group.filter(item=>item.data.role==='anchor');
60
60
  if (anchors.length!==1) throw new Error('Missing or duplicated metric anchor.');
61
61
  const anchor=anchors[0],manifest=anchor.data;validateManifest(manifest);
62
+ // RR-07: the trend arrow (role 'mark') is generated decoration: at most one, consumed with the group, never source text.
63
+ const marks=group.filter(item=>item.data.role==='mark'),members=group.filter(item=>item.data.role!=='mark');
64
+ if (marks.length>1) throw new Error('Duplicated metric trend mark.');
62
65
  const expected=manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
63
- if (group.length!==expected||new Set(group.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
66
+ if (members.length!==expected||new Set(members.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
64
67
  const nativeLines=new Map();
65
- for (const item of group) {
68
+ for (const item of members) {
66
69
  const {role,part,line}=item.data,key=`${part}:${line}`;
67
70
  if ((item!==anchor&&role!=='line')||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||!manifest.parts[part]?.lines[line]||nativeLines.has(key)) throw new Error('Ambiguous metric source line.');
68
71
  nativeLines.set(key,item.text);
@@ -5,6 +5,18 @@ const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false'
5
5
 
6
6
  const themeFamilies = context => ['a:majorFont', 'a:minorFont'].map(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase()).filter(Boolean);
7
7
 
8
+ // A run painted with a bare theme slot (a:schemeClr, no color transform) remembers the slot as the OPF color name: the
9
+ // transient `_opfScheme` marker is read, and always removed, by restoreRunColors (run-colors.js) once the document's own
10
+ // colour scheme can confirm the slot holds that colour.
11
+ const OPF_SLOTS = {dk1: 'dark1', lt1: 'light1', dk2: 'dark2', lt2: 'light2', hlink: 'hyperlink', folHlink: 'followedHyperlink', accent1: 'accent1', accent2: 'accent2', accent3: 'accent3', accent4: 'accent4', accent5: 'accent5', accent6: 'accent6'};
12
+ function nativeSchemeSlot(fill, context) {
13
+ const node = fill?.['a:schemeClr'];
14
+ if (!node || Array.isArray(node) || Object.keys(fill).length !== 1 || Object.keys(node).some(key => key !== 'val') || typeof node.val !== 'string') return undefined;
15
+ // Own keys only: a slot name from the package (`__proto__`, `constructor`) must never reach an inherited property.
16
+ const mapping = context?.mapping, slot = mapping && Object.hasOwn(mapping, node.val) ? mapping[node.val] : node.val;
17
+ return typeof slot === 'string' && Object.hasOwn(OPF_SLOTS, slot) ? OPF_SLOTS[slot] : undefined;
18
+ }
19
+
8
20
  export function nativeRunStyle(properties, context, relationships, report, kind = 'table') {
9
21
  const result = {};
10
22
  for (const [native, opf] of [['b','bold'], ['i','italic']]) {
@@ -42,7 +54,11 @@ export function nativeRunStyle(properties, context, relationships, report, kind
42
54
  }
43
55
  if (properties['a:solidFill']) {
44
56
  const color = readBackgroundColor(properties['a:solidFill'], context);
45
- if (color) result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
57
+ if (color) {
58
+ result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
59
+ const slot = color.alpha === 1 ? nativeSchemeSlot(properties['a:solidFill'], context) : undefined;
60
+ if (slot) result._opfScheme = slot;
61
+ }
46
62
  else report(`unsupported-${kind}-text-color`, 'The native text color or its transforms could not be resolved.');
47
63
  } else if (properties['a:noFill']) {
48
64
  result.color = '#00000000';
@@ -0,0 +1,114 @@
1
+ // RR-33: numbered lists. OPF `numbering` (a style, a start and a suffix per list level) is written as native
2
+ // `a:buAutoNum` and read back from it.
3
+ //
4
+ // Export: every marker line of a measured list is its own text box (see addMeasuredList), so each carries the number
5
+ // core counted as `startAt`; there is nothing for PowerPoint to count across boxes.
6
+ // Import: `a:buAutoNum` of a native paragraph is read with PowerPoint's own counting (consecutive paragraphs of one
7
+ // level and one scheme count up from `startAt`; a paragraph of another scheme or start begins a new sequence; a
8
+ // shallower or unnumbered paragraph ends the deeper sequences), then the numbers of the whole list are expressed as the
9
+ // shortest `numbering` plus per-entry `start` values that reproduce them.
10
+
11
+ const STYLES = ['arabic', 'roman-upper', 'roman-lower', 'alpha-upper', 'alpha-lower'];
12
+ const STYLE_STEMS = {arabic: 'arabic', 'roman-upper': 'romanUc', 'roman-lower': 'romanLc', 'alpha-upper': 'alphaUc', 'alpha-lower': 'alphaLc'};
13
+ const SUFFIX_TAILS = {period: 'Period', paren: 'ParenR', 'paren-both': 'ParenBoth'};
14
+
15
+ /** The `a:buAutoNum@type` scheme of an OPF style and suffix. */
16
+ export const autoNumScheme = (style, suffix = 'period') => `${STYLE_STEMS[style]}${SUFFIX_TAILS[suffix]}`;
17
+
18
+ const SCHEMES = new Map(STYLES.flatMap(style => Object.keys(SUFFIX_TAILS).map(suffix => [autoNumScheme(style, suffix), {style, suffix}])));
19
+ /** OPF style and suffix of a native scheme, or `undefined` for a scheme OPF has no equivalent for. */
20
+ export const schemeNumbering = type => SCHEMES.get(type);
21
+
22
+ /** `startAt` of a native auto-number: an integer from 1 to 32767, default 1. */
23
+ export function autoNumStart(value) {
24
+ if (value === undefined || value === '') return 1;
25
+ const number = Number(value);
26
+ return Number.isInteger(number) && number >= 1 && number <= 32767 ? number : undefined;
27
+ }
28
+
29
+ /**
30
+ * The number each paragraph of one native shape displays, by PowerPoint's counting. `paragraphs` are
31
+ * `{level, autoNum?: {type, startAt}}`; returns the displayed numbers (`undefined` for an unnumbered paragraph).
32
+ */
33
+ export function displayedNumbers(paragraphs) {
34
+ const sequences = [];
35
+ return paragraphs.map(paragraph => {
36
+ const level = Number.isInteger(paragraph.level) && paragraph.level >= 0 ? paragraph.level : 0;
37
+ if (!paragraph.autoNum) { sequences.length = Math.min(sequences.length, level); return undefined; }
38
+ const start = autoNumStart(paragraph.autoNum.startAt) ?? 1, type = paragraph.autoNum.type ?? 'arabicPeriod';
39
+ const current = sequences[level];
40
+ const value = current && current.type === type && current.start === start ? current.value + 1 : start;
41
+ sequences[level] = {type, start, value};
42
+ sequences.length = level + 1;
43
+ return value;
44
+ });
45
+ }
46
+
47
+ const entryOf = ({style, start, suffix}) => {
48
+ const fields = {...(style !== 'arabic' ? {style} : {}), ...(start !== 1 ? {start} : {}), ...(suffix !== 'period' ? {suffix} : {})};
49
+ const names = Object.keys(fields);
50
+ if (!names.length) return 'arabic';
51
+ return names.length === 1 && names[0] === 'style' ? style : fields;
52
+ };
53
+
54
+ /**
55
+ * The OPF numbering of a native list: `paragraphs` are `{level, number?: {type, value}}` in reading order (`number` is the
56
+ * displayed number and its scheme). Returns `{numbering, starts, diagnostics}`: `numbering` is the shortest authored form
57
+ * (`undefined` when the list is not numbered), `starts` maps a paragraph index to the entry `start` that restores its
58
+ * number, and `diagnostics` lists what could not be represented.
59
+ */
60
+ export function deriveListNumbering(paragraphs) {
61
+ const diagnostics = [];
62
+ const numbered = paragraphs.filter(paragraph => paragraph.number);
63
+ if (!numbered.length) return {numbering: undefined, starts: new Map(), diagnostics};
64
+ if (numbered.length !== paragraphs.length) {
65
+ diagnostics.push({code: 'numbering-mixed', message: 'A list that mixes numbered and bulleted or plain paragraphs has no OPF equivalent (numbering applies to the whole list); it imports as bullets.'});
66
+ return {numbering: undefined, starts: new Map(), diagnostics};
67
+ }
68
+ const levels = [];
69
+ let unknown = false, mixedStyles = false;
70
+ for (const paragraph of numbered) {
71
+ const level = paragraph.level ?? 0;
72
+ const known = schemeNumbering(paragraph.number.type);
73
+ if (!known) { unknown = true; }
74
+ const resolved = known ?? {style: 'arabic', suffix: 'period'};
75
+ if (!levels[level]) levels[level] = {...resolved, start: paragraph.number.value};
76
+ else if (levels[level].style !== resolved.style || levels[level].suffix !== resolved.suffix) mixedStyles = true;
77
+ }
78
+ if (unknown) diagnostics.push({code: 'numbering-scheme-adapted', message: 'A native numbering scheme OPF has no equivalent for (an East Asian, Hebrew or Arabic scheme, or a bullet-only scheme) imports as arabic with a period.'});
79
+ if (mixedStyles) diagnostics.push({code: 'numbering-style-adapted', message: 'Entries of one list level use different native numbering schemes; OPF numbers a level in one style, so the first entry\'s style is used for the level.'});
80
+ const filled = [];
81
+ for (let level = 0; level < levels.length; level++) filled[level] = levels[level] ?? {...(filled[level - 1] ?? {style: 'arabic', suffix: 'period'}), start: 1};
82
+ // Per-entry starts: restart wherever the displayed number is not what counting gives.
83
+ const counters = [], starts = new Map();
84
+ paragraphs.forEach((paragraph, index) => {
85
+ const level = paragraph.level ?? 0, value = paragraph.number.value;
86
+ const natural = counters[level] === undefined ? filled[level].start : counters[level] + 1;
87
+ if (natural !== value) starts.set(index, value);
88
+ counters[level] = value;
89
+ counters.length = level + 1;
90
+ });
91
+ // The last entry repeats for deeper levels, so trailing equal entries are redundant.
92
+ const same = (a, b) => a.style === b.style && a.suffix === b.suffix && a.start === b.start;
93
+ while (filled.length > 1 && same(filled.at(-1), filled.at(-2))) filled.pop();
94
+ const entries = filled.map(entryOf);
95
+ return {numbering: entries.length === 1 ? entries[0] : entries, starts, diagnostics};
96
+ }
97
+
98
+ /** A canonical form of an authored `numbering`, for comparing two spellings of the same numbering. */
99
+ export function canonicalNumbering(numbering) {
100
+ const list = Array.isArray(numbering) ? numbering : [numbering];
101
+ const resolved = list.map(entry => typeof entry === 'string' ? {style: entry, start: 1, suffix: 'period'} : {style: entry?.style ?? 'arabic', start: entry?.start ?? 1, suffix: entry?.suffix ?? 'period'});
102
+ while (resolved.length > 1 && JSON.stringify(resolved.at(-1)) === JSON.stringify(resolved.at(-2))) resolved.pop();
103
+ return JSON.stringify(resolved);
104
+ }
105
+
106
+ /**
107
+ * Add `autoNum` (the paragraph's `a:buAutoNum` attributes) and the displayed `number` ({type, value}) to the numbered
108
+ * paragraphs of one native shape. Paragraphs without a native auto-number are returned unchanged.
109
+ */
110
+ export function withDisplayedNumbers(paragraphs) {
111
+ if (!paragraphs.some(paragraph => paragraph.autoNum)) return paragraphs;
112
+ const numbers = displayedNumbers(paragraphs);
113
+ return paragraphs.map((paragraph, index) => numbers[index] === undefined ? paragraph : {...paragraph, number: {type: paragraph.autoNum.type ?? 'arabicPeriod', value: numbers[index]}});
114
+ }
@@ -0,0 +1,118 @@
1
+ // Run colours named by a reference (RR-08).
2
+ //
3
+ // A TextRun colour may be a ColorRef: a `var:` variable, a colour-scheme slot ('accent2') or a role ('primary'). The
4
+ // export resolves it (or writes the theme slot that holds the same colour) and PowerPoint has no field for the name, so
5
+ // import used to return only the resolved hex. Two sources can name the reference again, and both are gated by the
6
+ // imported document's own colour resolution: a name is restored only while it still resolves to the run's current colour,
7
+ // so a colour edited in PowerPoint keeps its edited value and nothing is invented.
8
+ //
9
+ // 1. Provenance (full mode): OPF_SLIDE_V1 `colors` = {n, at: [[slot, reference], ...]}. The slots are the text runs of the
10
+ // slide's content in a fixed order (slideRunSlots: text runs, list item and description runs; regions by name, then
11
+ // blocks and groups in order, or the root payload). `n` is how many slots the authored slide had; the record is used only
12
+ // when the imported slide has exactly that many, so a run can never be matched to another. It holds reference names
13
+ // only, never words.
14
+ // 2. A slide with no OPF record (plain PPTX): a run painted with a bare a:schemeClr (no colour transform) names that
15
+ // theme slot ('accent2', 'dark1', 'hyperlink'), restored when the document's colour scheme holds that exact colour for
16
+ // the slot. The exporter writes schemeClr only where the theme holds the colour, so this is the same convention.
17
+
18
+ import {ROOT_PAYLOAD_FIELDS, REGION_KEYS} from './content-topology.js';
19
+
20
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ const REGION_KEY_SET = new Set(REGION_KEYS);
22
+ const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
23
+ export const MAX_COLOR_SLOTS = 100000;
24
+
25
+ // A reference name: the schema's ColorRef forms other than a hex literal.
26
+ const REFERENCE = /^(?:var:[a-z][a-z0-9-]*|accent[1-6]|dark[12]|light[12]|hyperlink|followedHyperlink|primary|secondary|accent|background|surface|text|textSecondary)$/;
27
+ export const isColorReference = value => typeof value === 'string' && REFERENCE.test(value);
28
+
29
+ /** The payload objects of a slide in the fixed traversal order: regions (by name), else blocks (groups depth first), else the slide's root payload. */
30
+ function* leaves(slide) {
31
+ const walk = function* (block) {
32
+ if (!object(block)) return;
33
+ if (isGroup(block)) { for (const child of Array.isArray(block.blocks) ? block.blocks : []) yield* walk(child); return; }
34
+ yield block;
35
+ };
36
+ const regions = Object.keys(slide).filter(key => REGION_KEY_SET.has(key) && object(slide[key])).sort();
37
+ if (regions.length) { for (const key of regions) yield* walk(slide[key]); return; }
38
+ if (Array.isArray(slide.blocks)) { for (const block of slide.blocks) yield* walk(block); return; }
39
+ yield slide;
40
+ }
41
+
42
+ /** The text runs of the slide's text and list payloads, in order: an object (a run) or null (a plain string). */
43
+ export function slideRunSlots(slide) {
44
+ const slots = [];
45
+ const runs = value => {
46
+ if (Array.isArray(value)) for (const run of value) slots.push(object(run) ? run : null);
47
+ else if (typeof value === 'string') slots.push(null);
48
+ };
49
+ for (const leaf of leaves(slide)) {
50
+ for (const field of ROOT_PAYLOAD_FIELDS) {
51
+ const value = leaf[field];
52
+ if (field === 'text') { if (Array.isArray(value)) runs(value); }
53
+ else if ((field === 'items' || field === 'bullets') && Array.isArray(value)) {
54
+ for (const item of value) {
55
+ if (Array.isArray(item) || typeof item === 'string') runs(item);
56
+ else if (object(item)) { runs(item.text); runs(item.description); }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ return slots;
62
+ }
63
+
64
+ /** The record stored for an authored slide, or undefined when no run names a reference. */
65
+ export function runColorRecord(slide) {
66
+ const slots = slideRunSlots(slide);
67
+ if (slots.length > MAX_COLOR_SLOTS) return undefined;
68
+ const at = [];
69
+ slots.forEach((run, index) => { if (run && isColorReference(run.color)) at.push([index, run.color]); });
70
+ return at.length ? {n: slots.length, at} : undefined;
71
+ }
72
+
73
+ /** Throws when `value` is not a well-formed colours record. */
74
+ export function validateRunColors(value) {
75
+ if (!object(value) || !Number.isSafeInteger(value.n) || value.n < 1 || value.n > MAX_COLOR_SLOTS || !Array.isArray(value.at) || !value.at.length || value.at.length > value.n) throw Error('Invalid run colours record.');
76
+ let previous = -1;
77
+ for (const entry of value.at) {
78
+ if (!Array.isArray(entry) || entry.length !== 2 || !Number.isSafeInteger(entry[0]) || entry[0] <= previous || entry[0] >= value.n || !isColorReference(entry[1])) throw Error('Invalid run colour entry.');
79
+ previous = entry[0];
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function* markedRuns(value) {
85
+ if (Array.isArray(value)) { for (const item of value) yield* markedRuns(item); return; }
86
+ if (!object(value)) return;
87
+ if (Object.hasOwn(value, '_opfScheme')) yield value;
88
+ for (const item of Object.values(value)) if (item !== null && typeof item === 'object') yield* markedRuns(item);
89
+ }
90
+
91
+ /**
92
+ * Restore run colour references on `slides` (the imported document's slides). `info[i]` is
93
+ * {record, structure, content}: the stored OPF_SLIDE_V1 value, whether the slide's arrangement matches it and whether its
94
+ * content structure was restored from it, or undefined for an untagged slide. `resolve(slide, name)` returns the '#RRGGBB'
95
+ * the name resolves to in the document (or undefined). Every transient `_opfScheme` marker is removed.
96
+ */
97
+ export function restoreRunColors(slides, info, resolve, report) {
98
+ slides.forEach((slide, index) => {
99
+ const tag = info[index];
100
+ const marked = [...markedRuns(slide)];
101
+ const restoreName = (run, name) => {
102
+ const hex = typeof run.color === 'string' && /^#[0-9a-f]{6}$/i.test(run.color) ? run.color.toUpperCase() : undefined;
103
+ if (hex && resolve(slide, name)?.toUpperCase() === hex) run.color = name;
104
+ };
105
+ if (tag?.record?.colors) {
106
+ const {n, at} = tag.record.colors;
107
+ // When the content form was not restored its own diagnostic says why, and the slots cannot be aligned.
108
+ if (tag.structure && tag.content) {
109
+ const slots = slideRunSlots(slide);
110
+ if (slots.length !== n) report({code: 'color-reference-changed', path: `slides.${index}`, message: `The slide's text runs no longer match the stored colour references (${slots.length} runs now, ${n} at export), so the named run colours of slides.${index} (variables, scheme slots and roles) were not restored; the runs keep their resolved colours.`});
111
+ else for (const [slot, name] of at) if (slots[slot]) restoreName(slots[slot], name);
112
+ }
113
+ } else if (!tag?.record) {
114
+ for (const run of marked) if (typeof run._opfScheme === 'string') restoreName(run, run._opfScheme);
115
+ }
116
+ for (const run of marked) delete run._opfScheme;
117
+ });
118
+ }
@@ -13,6 +13,9 @@
13
13
  import * as opfCore from "@openpresentation/opf";
14
14
 
15
15
  const resolver = typeof opfCore.resolveScriptFonts === "function" ? opfCore.resolveScriptFonts : null;
16
+ // RR-05: authored alignment is logical for right-to-left text (`left` is the start edge). Core owns the rule; a core without it
17
+ // reports no line directions either, so the fallback never has anything to flip.
18
+ export const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
16
19
  // The one paragraph-direction rule shared with the renderer (core FF-07).
17
20
  const paragraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
18
21
 
@@ -126,24 +129,89 @@ const decodeText = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|q
126
129
  decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : {amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'"}[name.toLowerCase()]);
127
130
 
128
131
  /**
129
- * In a right-to-left deck every paragraph states its direction from core's
130
- * paragraphDirection() over its own text: rtl="1" when it is right-to-left,
131
- * else an explicit rtl="0", because the master default levels start
132
- * right-to-left. Alignment is left as composed.
132
+ * RR-05 bidi runs. PowerPoint treats the digits of a run tagged with a right-to-left language as numbers of that language and
133
+ * orders them as their own item, so "v2.0" or "PowerPoint 365" inside an Arabic or Hebrew run reads "2.0v" or "365PowerPoint".
134
+ * Splitting each left-to-right Latin phrase into its own run tagged en-US keeps it one item that PowerPoint orders left to right.
133
135
  */
134
- function paragraphRtl(xml, deckDirection) {
136
+ const LATIN_WORD = /[0-9\p{Script=Latin}]/u, LATIN_LETTER = /\p{Script=Latin}/u, PHRASE_JOINER = /[ \u00a0.,:;&'\u2019/+*_@#=-]/;
137
+
138
+ /** Ranges [start, end) of the Latin phrases of a decoded string: a Latin letter through the last Latin letter or digit joined by spaces and word punctuation. */
139
+ export function latinPhrases(text) {
140
+ const ranges = [];
141
+ for (let index = 0; index < text.length;) {
142
+ const char = String.fromCodePoint(text.codePointAt(index));
143
+ if (!LATIN_LETTER.test(char)) { index += char.length; continue; }
144
+ let end = index + char.length;
145
+ for (let cursor = end; cursor < text.length;) {
146
+ const next = String.fromCodePoint(text.codePointAt(cursor));
147
+ if (LATIN_WORD.test(next)) { cursor += next.length; end = cursor; continue; }
148
+ if (!PHRASE_JOINER.test(next)) break;
149
+ // Joiners count only when a Latin letter or digit follows them directly.
150
+ let after = cursor + next.length;
151
+ while (after < text.length && PHRASE_JOINER.test(text[after])) after += 1;
152
+ if (after < text.length && LATIN_WORD.test(String.fromCodePoint(text.codePointAt(after)))) cursor = after; else break;
153
+ }
154
+ ranges.push([index, end]);
155
+ index = end;
156
+ }
157
+ return ranges;
158
+ }
159
+
160
+ const encodeText = value => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
161
+ const RUN = /<a:r>(<a:rPr\b[^>]*\/>|<a:rPr\b[^>]*[^/]>[\s\S]*?<\/a:rPr>)<a:t>([^<]*)<\/a:t><\/a:r>/g;
162
+
163
+ /** Split the runs of a right-to-left paragraph so each Latin phrase is its own en-US run. */
164
+ function splitLatinRuns(body) {
165
+ return body.replace(RUN, (run, properties, escaped) => {
166
+ const text = decodeText(escaped), phrases = latinPhrases(text);
167
+ if (!phrases.length) return run;
168
+ const make = (value, lang) => `<a:r>${lang ? properties.replace(/\slang="[^"]*"/, ` lang="${lang}"`) : properties}<a:t>${encodeText(value)}</a:t></a:r>`;
169
+ const pieces = [];
170
+ let cursor = 0;
171
+ for (const [from, to] of phrases) {
172
+ if (from > cursor) pieces.push(make(text.slice(cursor, from)));
173
+ pieces.push(make(text.slice(from, to), "en-US"));
174
+ cursor = to;
175
+ }
176
+ if (cursor < text.length) pieces.push(make(text.slice(cursor)));
177
+ return pieces.join("");
178
+ });
179
+ }
180
+
181
+ /**
182
+ * In a right-to-left deck every paragraph states its direction. The writers mark the lines of a paragraph they laid out
183
+ * (rtl="1", the direction core computed once for the whole paragraph, so wrapped lines never differ); any other paragraph takes
184
+ * core's paragraphDirection() over its own text: rtl="1" when it is right-to-left, else an explicit rtl="0", because the master
185
+ * default levels start right-to-left. A right-to-left paragraph with no explicit alignment starts at the right edge (logical
186
+ * alignment: the master default is left), and its Latin phrases become en-US runs (see splitLatinRuns). With `notes` the
187
+ * alignment is also set on an explicit left paragraph, because the notes writers pass no alignment of their own.
188
+ */
189
+ function paragraphRtl(xml, deckDirection, {notes = false} = {}) {
135
190
  return xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g, (paragraph, body) => {
136
191
  const text = [...body.matchAll(/<a:t>([^<]*)<\/a:t>|<a:br\b/g)].map(match => match[1] === undefined ? "\n" : decodeText(match[1])).join("");
137
- const value = paragraphDirection(text, deckDirection) === "rtl" ? "1" : "0";
138
192
  const properties = /^(\s*)<a:pPr\b([^>]*?)(\/?)>/.exec(body);
139
- if (!properties) return `<a:p><a:pPr rtl="${value}"/>${body}</a:p>`;
140
- const attributes = / rtl="[^"]*"/.test(properties[2]) ? properties[2].replace(/ rtl="[^"]*"/, ` rtl="${value}"`) : `${properties[2]} rtl="${value}"`;
193
+ const marked = properties && / rtl="([01])"/.exec(properties[2]);
194
+ const rtl = marked ? marked[1] === "1" : paragraphDirection(text, deckDirection) === "rtl";
195
+ const value = rtl ? "1" : "0";
196
+ if (rtl) body = splitLatinRuns(body);
197
+ if (!properties) return `<a:p><a:pPr rtl="${value}"${rtl ? " algn=\"r\"" : ""}/>${body}</a:p>`;
198
+ let attributes = / rtl="[^"]*"/.test(properties[2]) ? properties[2].replace(/ rtl="[^"]*"/, ` rtl="${value}"`) : `${properties[2]} rtl="${value}"`;
199
+ if (rtl) {
200
+ if (!/\salgn="/.test(attributes)) attributes += " algn=\"r\"";
201
+ else if (notes) attributes = attributes.replace(/ algn="l"/, " algn=\"r\"");
202
+ }
141
203
  return `<a:p>${properties[1]}<a:pPr${attributes}${properties[3]}>${body.slice(properties[0].length)}</a:p>`;
142
204
  });
143
205
  }
144
206
 
145
- /** Master, layout and presentation default paragraph levels start right-to-left. */
146
- const levelRtl = xml => xml.replace(/(<a:(?:lvl\dpPr|defPPr)\b[^>]*?\srtl=")0(")/g, "$11$2");
207
+ /** Tables of a right-to-left deck run right to left: the first column is the rightmost, as core's layout draws it. */
208
+ const tableRtl = xml => xml.replace(/<a:tblPr\b([^>]*?)(\/?)>/g, (match, attributes, close) => / rtl="/.test(attributes) ? match : `<a:tblPr rtl="1"${attributes}${close}>`);
209
+
210
+ /**
211
+ * Master, layout and presentation default paragraph levels start right-to-left, and a left-aligned level starts at the right edge
212
+ * (logical alignment): text typed later in an Arabic or Hebrew deck then reads and aligns as the exported slides do.
213
+ */
214
+ const levelRtl = xml => xml.replace(/<a:(?:lvl\dpPr|defPPr)\b[^>]*?\srtl="0"[^>]*>/g, level => level.replace(/\srtl="0"/, " rtl=\"1\"").replace(/\salgn="l"/, " algn=\"r\""));
147
215
 
148
216
  /**
149
217
  * Apply the plan to one generated XML part. `slideIndex` names the slide a
@@ -157,10 +225,10 @@ export function partScriptFonts(path, xml, plan, slideIndex) {
157
225
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
158
226
  xml = xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape =>
159
227
  runScriptFonts(shape, resolved, /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape)));
160
- if (plan.rtl) xml = paragraphRtl(xml, plan.deck.direction);
228
+ if (plan.rtl) xml = tableRtl(paragraphRtl(xml, plan.deck.direction));
161
229
  } else if (/^ppt\/(?:charts\/chart(?:Ex)?|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
162
230
  xml = runScriptFonts(xml, resolved, false);
163
- if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction);
231
+ if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction, {notes: true});
164
232
  } else if (plan.rtl && /^ppt\/(?:slideMasters\/slideMaster\d+|slideLayouts\/slideLayout\d+|notesMasters\/notesMaster\d+|presentation)\.xml$/.test(path)) {
165
233
  xml = levelRtl(xml);
166
234
  }
@@ -252,6 +320,12 @@ export function reconcileLanguage(groups, observed, report) {
252
320
  * language, and theme East Asian/complex-script fonts the document does not
253
321
  * reproduce.
254
322
  */
323
+ /** Whether the language observed in the runs is written right to left. */
324
+ export function observedRtl(observed) {
325
+ if (observed.lang === undefined) return false;
326
+ try { return resolver ? resolver({language: observed.language}).rtl === true : observed.match?.record.direction === "rtl"; } catch { return false; }
327
+ }
328
+
255
329
  export function languageDiagnostics(imported, observed, report) {
256
330
  if (!report) return;
257
331
  const {lang, match, ranked, rtlParagraphs, theme} = observed;
@@ -259,9 +333,11 @@ export function languageDiagnostics(imported, observed, report) {
259
333
  if (rtlParagraphs) report({code: "rtl-language-mismatch", path: "language", message: `${rtlParagraphs} right-to-left paragraph(s) carry no run language, so no presentation language was imported.`});
260
334
  return;
261
335
  }
262
- if (ranked.length > 1) {
336
+ // RR-05: a right-to-left deck's Latin phrases are their own en-US runs; they are not a second presentation language.
337
+ const languages = observedRtl(observed) ? ranked.filter(([tag]) => tag === lang || tag !== "en-US") : ranked;
338
+ if (languages.length > 1) {
263
339
  report({code: "mixed-run-languages", path: "language",
264
- message: `Runs use ${ranked.length} languages (${ranked.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported.`});
340
+ message: `Runs use ${languages.length} languages (${languages.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported.`});
265
341
  }
266
342
  const kept = imported.language === observed.language;
267
343
  if (kept && !match) report({code: "language-uncatalogued", path: "language", message: `Run language ${lang} matches no languages catalog record; it was imported as a BCP-47 tag.`});
@@ -63,7 +63,9 @@ export function nativeCellStyle(properties, body, context, scale, report) {
63
63
  const listStyle = drawingObject(child(body,'a:lstStyle'));
64
64
  const alignments = nodes(body,'a:p').map(paragraph => {
65
65
  const p = drawingObject(paragraph['a:p'])['a:pPr'] ?? {};
66
- return p.algn ?? listStyle[`a:lvl${Number(p.lvl ?? 0)+1}pPr`]?.algn ?? listStyle['a:defPPr']?.algn ?? 'l';
66
+ const algn = p.algn ?? listStyle[`a:lvl${Number(p.lvl ?? 0)+1}pPr`]?.algn ?? listStyle['a:defPPr']?.algn ?? 'l';
67
+ // Alignment is logical for right-to-left text (RR-05): an rtl paragraph aligned right starts at the start edge.
68
+ return p.rtl === '1' || p.rtl === 1 ? ({l:'r',r:'l'}[algn] ?? algn) : algn;
67
69
  });
68
70
  const alignment = {l:'left',ctr:'center',r:'right'}[alignments[0] ?? 'l'];
69
71
  if (alignment) style.align = alignment;
@@ -121,7 +121,7 @@ function conditionalProperties(style, properties, row, column, rowCount, columnC
121
121
 
122
122
  // Use the same direct graphic-frame ordering as the slide collector. The ordered
123
123
  // reader retains interleaved runs, fields, breaks, empty paragraphs and spaces.
124
- export function importTableFrames(slidePath, archive, relationships, report, dimensions) {
124
+ export function importTableFrames(slidePath, archive, relationships, report, dimensions, {rtlDeck = false} = {}) {
125
125
  const context = readSlideTheme(slidePath, archive);
126
126
  const scale = Math.min(dimensions.widthInches,dimensions.heightInches) * 96 / 720;
127
127
  const root = child(context.parsedPart(slidePath)?.tree, 'p:sld');
@@ -133,7 +133,8 @@ export function importTableFrames(slidePath, archive, relationships, report, dim
133
133
  const style = tableStyle(props, archive, context, (code, message) => report(frameIndex, '', code, message));
134
134
  if (style?.['a:tblBg']) report(frameIndex, '', 'unsupported-table-cell-style', 'The native table style background is not represented; supported character styles are retained.');
135
135
  const properties = attrs(table, 'a:tblPr');
136
- if (boolean(properties.rtl) === true) report(frameIndex, '', 'unsupported-table-direction', 'Right-to-left table geometry is not represented; cells retain native source order.');
136
+ // A right-to-left table of a right-to-left deck keeps its logical column order (RR-05); the deck language carries the direction.
137
+ if (boolean(properties.rtl) === true && !rtlDeck) report(frameIndex, '', 'unsupported-table-direction', 'Right-to-left table geometry is not represented; cells retain native source order.');
137
138
  const headers = boolean(properties.firstRow) === true;
138
139
  const nativeRows = nodes(table, 'a:tr');
139
140
  const columnCount = nodes(child(table, 'a:tblGrid'), 'a:gridCol').length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.11.9",
3
+ "version": "0.12.0",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,8 +37,8 @@
37
37
  "sideEffects": false,
38
38
  "scripts": {
39
39
  "build": "node scripts/build.mjs",
40
- "typecheck": "node --check src/index.js && node --check src/svg-image.js && node --check test/svg-image.mjs && node --check test/native-svg-decks.mjs && node --check src/chart-types.js && node --check src/chartex.js && node --check test/chartex.mjs && node --check test/chart-text-size.mjs && node --check test/chart-types.mjs && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check test/tag-colour.mjs && node --check test/table-theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check src/watermark-provenance.js && node --check src/logo-provenance.js && node --check test/design-fields.mjs && node --check src/media-dedupe.js && node --check test/media-dedupe.mjs && node --check test/watermark.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check src/quote-provenance.js && node --check test/quote-provenance.mjs && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check src/content-topology.js && node --check src/sections.js && node --check test/document-provenance.mjs && node --check test/content-topology.mjs && node --check test/sections.mjs && node --check test/provenance-interop.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check src/native-furniture.js && node --check test/native-furniture.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/theme-script-slots.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/svg-image-degrade.mjs && node --check test/background-colorref.mjs && node --check test/import-fabrications.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs && node --check test/gallery-layout-alignment.mjs && node --check src/import-signals.js && node --check test/import-signals.mjs && node --check test/signals-deck.mjs",
41
- "test": "npm run build && node test/chart-types.mjs && node test/chartex.mjs && node test/chart-text-size.mjs && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/watermark.mjs && node test/design-fields.mjs && node test/media-dedupe.mjs && node test/document-provenance.mjs && node test/content-topology.mjs && node test/sections.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/theme-script-slots.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/tag-colour.mjs && node test/table-theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/gallery-layout-alignment.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/svg-image-degrade.mjs && node test/svg-image.mjs && node test/background-colorref.mjs && node test/import-fabrications.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && node test/quote-provenance.mjs && node test/import-signals.mjs && npm run test:timeline && npm run test:determinism",
40
+ "typecheck": "node --check src/index.js && node --check src/annotation-export.js && node --check src/annotation-provenance.js && node --check test/annotations.mjs && node --check test/region-reading-order.mjs && node --check test/symbol-fonts.mjs && node --check test/math-emoji.mjs && node --check src/svg-image.js && node --check test/svg-image.mjs && node --check test/native-svg-decks.mjs && node --check src/chart-types.js && node --check src/chartex.js && node --check src/chart-options.js && node --check test/chart-options.mjs && node --check test/chartex.mjs && node --check test/chart-text-size.mjs && node --check test/chart-types.mjs && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check test/tag-colour.mjs && node --check test/table-theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check src/watermark-provenance.js && node --check src/logo-provenance.js && node --check test/design-fields.mjs && node --check src/media-dedupe.js && node --check test/media-dedupe.mjs && node --check test/watermark.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/single-series-colour.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check src/quote-provenance.js && node --check test/quote-provenance.mjs && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check src/content-topology.js && node --check src/sections.js && node --check test/document-provenance.mjs && node --check test/content-topology.mjs && node --check test/sections.mjs && node --check test/provenance-interop.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check src/native-furniture.js && node --check test/native-furniture.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/rtl-layout.mjs && node --check test/theme-script-slots.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/svg-image-degrade.mjs && node --check test/background-colorref.mjs && node --check test/import-fabrications.mjs && node --check src/run-colors.js && node --check test/hard-line-breaks.mjs && node --check test/import-details.mjs && node --check test/list-shape-names.mjs && node --check src/numbered-list.js && node --check test/numbered-lists.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs && node --check test/gallery-layout-alignment.mjs && node --check src/import-signals.js && node --check test/import-signals.mjs && node --check test/signals-deck.mjs && node --check test/rr-07-preview-polish.mjs && node --check scripts/rr07-native-set.mjs",
41
+ "test": "npm run build && node test/annotations.mjs && node test/region-reading-order.mjs && node test/symbol-fonts.mjs && node test/math-emoji.mjs && node test/chart-types.mjs && node test/chartex.mjs && node test/chart-options.mjs && node test/chart-text-size.mjs && node test/zip-date.mjs && node test/background-fills.mjs && node test/rr-07-preview-polish.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/watermark.mjs && node test/design-fields.mjs && node test/media-dedupe.mjs && node test/document-provenance.mjs && node test/content-topology.mjs && node test/sections.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/rtl-layout.mjs && node test/theme-script-slots.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/tag-colour.mjs && node test/table-theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/single-series-colour.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/gallery-layout-alignment.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/svg-image-degrade.mjs && node test/svg-image.mjs && node test/background-colorref.mjs && node test/import-fabrications.mjs && node test/hard-line-breaks.mjs && node test/import-details.mjs && node test/list-shape-names.mjs && node test/numbered-lists.mjs && node test/shared-quote.mjs && node test/quote-provenance.mjs && node test/import-signals.mjs && npm run test:timeline && npm run test:determinism && node test/template-variables.mjs",
42
42
  "validate": "node scripts/validate-package.mjs",
43
43
  "test:packed": "node test/packed-install.mjs && node test/provenance-interop.mjs",
44
44
  "test:compare-published": "node test/compare-published.mjs",
@@ -61,14 +61,14 @@
61
61
  "test:determinism": "node test/determinism.mjs"
62
62
  },
63
63
  "dependencies": {
64
- "@openpresentation/opf": "^0.11.4",
64
+ "@openpresentation/opf": "^0.12.0",
65
65
  "fast-xml-parser": "^5.8.0",
66
66
  "fflate": "^0.8.3",
67
67
  "jszip": "3.10.2",
68
68
  "sharp": "0.35.4"
69
69
  },
70
70
  "peerDependencies": {
71
- "@openpresentation/opf-render": "^0.11.0"
71
+ "@openpresentation/opf-render": "^0.12.0"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@openpresentation/opf-render": {
@@ -77,7 +77,7 @@
77
77
  },
78
78
  "devDependencies": {
79
79
  "@expo-google-fonts/carlito": "0.4.1",
80
- "@openpresentation/opf-render": "0.11.9",
80
+ "@openpresentation/opf-render": "0.12.0",
81
81
  "esbuild": "0.28.2",
82
82
  "playwright": "1.63.0"
83
83
  },