@forevka/wordcanvas 0.7.0 → 0.7.1

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 (47) hide show
  1. package/README.md +48 -1
  2. package/dist-lib/assets/{worker-BsOsK39A.js → worker-D8rUOKfg.js} +9603 -9451
  3. package/dist-lib/assets/worker-wXuuyshQ.js +2258 -0
  4. package/dist-lib/{blockFactory-Bcy0U40y.js → blockFactory-CUI4Rqiq.js} +50 -42
  5. package/dist-lib/{browser-5Sv4CuNy.js → browser-DkKxreJM.js} +404 -383
  6. package/dist-lib/builder.js +364 -109
  7. package/dist-lib/editorApp-DLXMmtu5.js +8023 -0
  8. package/dist-lib/export.js +3820 -3768
  9. package/dist-lib/generate-toc.js +4 -4
  10. package/dist-lib/import.js +4 -1538
  11. package/dist-lib/{lists-BegzmTmD.js → lists-Sc5I40JO.js} +2 -2
  12. package/dist-lib/pipeline-CbxmCE84.js +1705 -0
  13. package/dist-lib/recalc-docx.js +2 -2
  14. package/dist-lib/recalcToc-g1hFKSNs.js +3935 -0
  15. package/dist-lib/toc-B6VpkJge.js +213 -0
  16. package/dist-lib/{webmcp-B7Gvd58E.js → webmcp-6WxRNKCc.js} +2 -2
  17. package/dist-lib/{wordcanvas-BAxBeErX.js → wordcanvas-CFqpDK0k.js} +1 -1
  18. package/dist-lib/wordcanvas.js +1 -1
  19. package/dist-node/{chunk-GXFDF7UY.js → chunk-22JHAERT.js} +95 -23
  20. package/dist-node/chunk-22JHAERT.js.map +7 -0
  21. package/dist-node/{chunk-2RCPB7UZ.js → chunk-6AJJMHKY.js} +1 -1
  22. package/dist-node/chunk-6AJJMHKY.js.map +7 -0
  23. package/dist-node/{chunk-XDUDPPJW.js → chunk-CDN6WU2P.js} +89 -26
  24. package/dist-node/chunk-CDN6WU2P.js.map +7 -0
  25. package/dist-node/{chunk-TBSXA2I4.js → chunk-EGSAVPCC.js} +1 -1
  26. package/dist-node/chunk-EGSAVPCC.js.map +7 -0
  27. package/dist-node/{chunk-IJLL6IFJ.js → chunk-RNB336F3.js} +116 -5
  28. package/dist-node/{chunk-IJLL6IFJ.js.map → chunk-RNB336F3.js.map} +2 -2
  29. package/dist-node/{engine-FRCX7PXF.js → engine-5FGDJD2H.js} +3 -3
  30. package/dist-node/export.js +92 -31
  31. package/dist-node/export.js.map +2 -2
  32. package/dist-node/generate-toc.js +5 -5
  33. package/dist-node/import.js +3 -3
  34. package/dist-node/recalc-docx.js +4 -4
  35. package/dist-node/recalc-docx.js.map +1 -1
  36. package/package.json +1 -1
  37. package/types/model.d.ts +231 -225
  38. package/types/recalc-docx.d.ts +27 -27
  39. package/dist-lib/assets/worker-BbFAIxdy.js +0 -2163
  40. package/dist-lib/editorApp-DvjFPHiy.js +0 -7582
  41. package/dist-lib/recalcToc-uaLWt59L.js +0 -3835
  42. package/dist-lib/toc-CZel_Kk3.js +0 -127
  43. package/dist-node/chunk-2RCPB7UZ.js.map +0 -7
  44. package/dist-node/chunk-GXFDF7UY.js.map +0 -7
  45. package/dist-node/chunk-TBSXA2I4.js.map +0 -7
  46. package/dist-node/chunk-XDUDPPJW.js.map +0 -7
  47. /package/dist-node/{engine-FRCX7PXF.js.map → engine-5FGDJD2H.js.map} +0 -0
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../shared/src/model/text.ts", "../../shared/src/model/lists.ts", "../../shared/src/model/ops.ts", "../../shared/src/toc.ts", "../../shared/src/ids.ts", "../../shared/src/fields.ts"],
4
- "sourcesContent": ["// Shared text helpers over the model: paragraph text access, grapheme/word\r\n// boundaries (Intl.Segmenter \u2014 the same segmentation pretext breaks lines on),\r\n// and style-at-position for Word-style inheritance when typing.\r\n\r\nimport type { BandContainer, Block, CharStyle, Document, Paragraph, Run } from \"./document\";\r\nimport { BAND_CONTAINERS } from \"./document\";\r\n\r\nexport const graphemes = new Intl.Segmenter(undefined, { granularity: \"grapheme\" });\r\nexport const words = new Intl.Segmenter(undefined, { granularity: \"word\" });\r\n\r\n/** A paragraph whose every non-empty run is hidden (w:vanish): it has text but\r\n * none is visible. Such a paragraph is never laid out, caret-reachable, or\r\n * deletable \u2014 it's preserved metadata. A truly empty paragraph (no text at all)\r\n * is NOT hidden \u2014 it's a normal blank line. */\r\nexport const isHiddenParagraph = (p: Paragraph): boolean =>\r\n p.runs.some((r) => r.text.length > 0) && p.runs.every((r) => r.text.length === 0 || r.style.hidden === true);\r\n\r\nconst paragraphsInBlocks = (blocks: Block[], includeCells: boolean): Paragraph[] => {\r\n const out: Paragraph[] = [];\r\n for (const b of blocks) {\r\n if (b.kind === \"paragraph\") out.push(b);\r\n else if (b.kind === \"table\" && includeCells) {\r\n for (const row of b.rows) {\r\n for (const cell of row.cells) out.push(...paragraphsInBlocks(cell.blocks, includeCells));\r\n }\r\n }\r\n }\r\n return out;\r\n};\r\n\r\n/** The two band STORIES from the UI's point of view (story mode is entered per\r\n * header/footer area; which variant CONTAINER that resolves to depends on the\r\n * page \u2014 see Page.headerSource/footerSource). */\r\nexport type BandName = \"header\" | \"footer\";\r\n\r\n/** Editable paragraphs of a margin band container, document order \u2014 INCLUDING\r\n * band-table cells (imported footers are routinely tables holding text next\r\n * to a page-number paragraph). */\r\nexport const bandParagraphs = (doc: Document, band: BandContainer): Paragraph[] =>\r\n paragraphsInBlocks(doc.section[band] ?? [], true);\r\n\r\n/** Body paragraphs in document order, INCLUDING table-cell paragraphs (one\r\n * level deep). Excludes margin bands and footnote notes \u2014 the body story only.\r\n * Heading/TOC scans use this so headings inside table cells are found too. */\r\nexport const bodyParagraphs = (doc: Document): Paragraph[] => paragraphsInBlocks(doc.blocks, true);\r\n\r\n/** All editable paragraphs in document order: body (including table cells),\r\n * then every band story (header/footer + first/even variants). This is the\r\n * index space commands use. */\r\nexport const paragraphsOf = (doc: Document): Paragraph[] => [\r\n ...paragraphsInBlocks(doc.blocks, true),\r\n ...BAND_CONTAINERS.flatMap((band) => bandParagraphs(doc, band)),\r\n ...Object.values(doc.footnotes ?? {}).flat(),\r\n];\r\n\r\nexport const blockById = (doc: Document, blockId: string): Paragraph | undefined =>\r\n paragraphsOf(doc).find((b) => b.id === blockId);\r\n\r\nexport const blockIndexOf = (doc: Document, blockId: string): number =>\r\n paragraphsOf(doc).findIndex((b) => b.id === blockId);\r\n\r\n// ---------------------------------------------------------------------------\r\n// Paragraph locator \u2014 content ops work on any paragraph, wherever it lives.\r\n\r\nexport type ParaLocation =\r\n | { kind: \"top\"; bi: number }\r\n | { kind: \"cell\"; where: \"body\" | BandContainer; bi: number; ri: number; ci: number; pi: number }\r\n | { kind: \"band\"; band: BandContainer; bi: number }\r\n | { kind: \"footnote\"; noteId: string; pi: number };\r\n\r\n/** Top-level block list of a container (\"body\" or a band story). */\r\nexport const containerListOf = (doc: Document, where: \"body\" | BandContainer): Block[] =>\r\n where === \"body\" ? doc.blocks : (doc.section[where] ?? []);\r\n\r\nfunction locateInBlocks(\r\n blocks: Block[],\r\n where: \"body\" | BandContainer,\r\n blockId: string,\r\n): ParaLocation | null {\r\n for (let bi = 0; bi < blocks.length; bi++) {\r\n const b = blocks[bi]!;\r\n if (b.kind === \"paragraph\") {\r\n if (b.id === blockId) {\r\n return where === \"body\" ? { kind: \"top\", bi } : { kind: \"band\", band: where, bi };\r\n }\r\n } else if (b.kind === \"table\") {\r\n for (let ri = 0; ri < b.rows.length; ri++) {\r\n const row = b.rows[ri]!;\r\n for (let ci = 0; ci < row.cells.length; ci++) {\r\n const cell = row.cells[ci]!;\r\n for (let pi = 0; pi < cell.blocks.length; pi++) {\r\n const cb = cell.blocks[pi]!;\r\n // One level deep: paragraphs of nested tables stay read-only.\r\n if (cb.kind === \"paragraph\" && cb.id === blockId) {\r\n return { kind: \"cell\", where, bi, ri, ci, pi };\r\n }\r\n }\r\n }\r\n }\r\n }\r\n }\r\n return null;\r\n}\r\n\r\nexport function locateParagraph(doc: Document, blockId: string): ParaLocation | null {\r\n const body = locateInBlocks(doc.blocks, \"body\", blockId);\r\n if (body) return body;\r\n for (const band of BAND_CONTAINERS) {\r\n const hit = locateInBlocks(doc.section[band] ?? [], band, blockId);\r\n if (hit) return hit;\r\n }\r\n for (const [noteId, paras] of Object.entries(doc.footnotes ?? {})) {\r\n const pi = paras.findIndex((p) => p.id === blockId);\r\n if (pi >= 0) return { kind: \"footnote\", noteId, pi };\r\n }\r\n return null;\r\n}\r\n\r\nexport function paragraphAt(doc: Document, loc: ParaLocation): Paragraph {\r\n if (loc.kind === \"top\") return doc.blocks[loc.bi] as Paragraph;\r\n if (loc.kind === \"band\") return doc.section[loc.band]![loc.bi] as Paragraph;\r\n if (loc.kind === \"footnote\") return doc.footnotes![loc.noteId]![loc.pi]!;\r\n const table = containerListOf(doc, loc.where)[loc.bi] as Extract<Block, { kind: \"table\" }>;\r\n return table.rows[loc.ri]!.cells[loc.ci]!.blocks[loc.pi] as Paragraph;\r\n}\r\n\r\n/** Immutable path-clone replace; bumps the containing table's revision too. */\r\nexport function replaceParagraphAt(doc: Document, loc: ParaLocation, p: Paragraph): Document {\r\n if (loc.kind === \"footnote\") {\r\n const paras = doc.footnotes![loc.noteId]!.slice();\r\n paras[loc.pi] = p;\r\n return { ...doc, footnotes: { ...doc.footnotes, [loc.noteId]: paras } };\r\n }\r\n if (loc.kind === \"band\") {\r\n const blocks = (doc.section[loc.band] ?? []).slice();\r\n blocks[loc.bi] = p;\r\n return { ...doc, section: { ...doc.section, [loc.band]: blocks } };\r\n }\r\n if (loc.kind === \"top\") {\r\n const blocks = doc.blocks.slice();\r\n blocks[loc.bi] = p;\r\n return { ...doc, blocks };\r\n }\r\n const blocks = containerListOf(doc, loc.where).slice();\r\n const table = blocks[loc.bi] as Extract<Block, { kind: \"table\" }>;\r\n const rows = table.rows.slice();\r\n const row = { cells: rows[loc.ri]!.cells.slice() };\r\n const cell = { ...row.cells[loc.ci]!, blocks: row.cells[loc.ci]!.blocks.slice() };\r\n cell.blocks[loc.pi] = p;\r\n row.cells[loc.ci] = cell;\r\n rows[loc.ri] = row;\r\n blocks[loc.bi] = { ...table, rows, revision: table.revision + 1 };\r\n if (loc.where === \"body\") return { ...doc, blocks };\r\n return { ...doc, section: { ...doc.section, [loc.where]: blocks } };\r\n}\r\n\r\nexport const isInCell = (doc: Document, blockId: string): boolean =>\r\n locateParagraph(doc, blockId)?.kind === \"cell\";\r\n\r\nexport const textOfRuns = (runs: Run[]): string => runs.map((r) => r.text).join(\"\");\r\n\r\nexport const textOfBlock = (doc: Document, blockId: string): string => {\r\n const b = blockById(doc, blockId);\r\n return b ? textOfRuns(b.runs) : \"\";\r\n};\r\n\r\nexport function prevGrapheme(text: string, offset: number): number {\r\n let prev = 0;\r\n for (const s of graphemes.segment(text)) {\r\n if (s.index >= offset) break;\r\n prev = s.index;\r\n }\r\n return prev;\r\n}\r\n\r\nexport function nextGrapheme(text: string, offset: number): number {\r\n for (const s of graphemes.segment(text)) {\r\n const end = s.index + s.segment.length;\r\n if (end > offset) return end;\r\n }\r\n return text.length;\r\n}\r\n\r\nexport function prevWordStart(text: string, offset: number): number {\r\n let prev = 0;\r\n for (const s of words.segment(text)) {\r\n if (s.index >= offset) break;\r\n if (s.isWordLike) prev = s.index;\r\n }\r\n return prev;\r\n}\r\n\r\nexport function nextWordEnd(text: string, offset: number): number {\r\n for (const s of words.segment(text)) {\r\n const end = s.index + s.segment.length;\r\n if (s.isWordLike && end > offset) return end;\r\n }\r\n return text.length;\r\n}\r\n\r\n/** Style of the character before `offset` (Word: typing inherits what precedes\r\n * the caret), falling back to the first run's style. */\r\nexport function styleAtRuns(runs: Run[], offset: number): CharStyle | undefined {\r\n let cum = 0;\r\n for (const r of runs) {\r\n const end = cum + r.text.length;\r\n // offset-1 falls inside this run -> its style precedes the caret\r\n if (offset > cum && offset <= end && r.text.length > 0) return r.style;\r\n cum = end;\r\n }\r\n return runs[0]?.style;\r\n}\r\n", "// List definitions \u2014 a direct mirror of OOXML numbering.xml abstractNum levels,\n// so the docx importer can feed Document.lists losslessly (id space = numId).\n// Markers are PAINT-ONLY: they live in the hanging indent and never affect line\n// breaking (matching Word's overlap behavior for very long markers).\n\nimport type { CharStyle } from \"./document\";\n\nexport type ListNumberFormat =\n | \"bullet\"\n | \"decimal\"\n | \"lowerLetter\"\n | \"upperLetter\"\n | \"lowerRoman\"\n | \"upperRoman\";\n\nexport interface ListLevel {\n format: ListNumberFormat;\n /** Marker pattern; %N is the counter of level N-1, formatted per THAT level\n * (OOXML \u00A717.9.11 semantics). E.g. \"%1.\" or \"%1.%2.%3\". Ignored for bullets. */\n text: string;\n bulletChar?: string;\n /** Text indent for paragraphs at this level (added to ParaStyle.indentLeftPx). */\n indentLeftPx: number;\n /** Marker hangs this far left of the text origin. */\n hangingPx: number;\n start: number;\n markerStyle?: Partial<CharStyle>;\n}\n\nexport interface ListDefinition {\n id: string;\n levels: ListLevel[]; // up to 9 (levels 0..8)\n}\n\n// ---------------------------------------------------------------------------\n// Number formatting\n\nconst ROMAN: [number, string][] = [\n [1000, \"m\"], [900, \"cm\"], [500, \"d\"], [400, \"cd\"], [100, \"c\"], [90, \"xc\"],\n [50, \"l\"], [40, \"xl\"], [10, \"x\"], [9, \"ix\"], [5, \"v\"], [4, \"iv\"], [1, \"i\"],\n];\n\nfunction toRoman(n: number): string {\n let out = \"\";\n let v = Math.max(1, n);\n for (const [value, sym] of ROMAN) {\n while (v >= value) {\n out += sym;\n v -= value;\n }\n }\n return out;\n}\n\nfunction toLetter(n: number): string {\n // 1->a .. 26->z, 27->aa (Word repeats: 27 is actually \"aa\")\n let out = \"\";\n let v = Math.max(1, n);\n while (v > 0) {\n v -= 1;\n out = String.fromCharCode(97 + (v % 26)) + out;\n v = Math.floor(v / 26);\n }\n return out;\n}\n\nexport function formatListNumber(n: number, format: ListNumberFormat): string {\n switch (format) {\n case \"decimal\": return String(n);\n case \"lowerLetter\": return toLetter(n);\n case \"upperLetter\": return toLetter(n).toUpperCase();\n case \"lowerRoman\": return toRoman(n);\n case \"upperRoman\": return toRoman(n).toUpperCase();\n case \"bullet\": return \"\";\n }\n}\n\n/** Render a level's marker from the counter stack (counters[k] = level k's value). */\nexport function markerText(def: ListDefinition, level: number, counters: number[]): string {\n const lvl = def.levels[Math.min(level, def.levels.length - 1)];\n if (!lvl) return \"\";\n if (lvl.format === \"bullet\") return lvl.bulletChar ?? \"\u2022\";\n return lvl.text.replace(/%(\\d)/g, (_, d: string) => {\n const k = Number(d) - 1;\n const refLevel = def.levels[Math.min(k, def.levels.length - 1)];\n const value = counters[k] ?? refLevel?.start ?? 1;\n return formatListNumber(value, refLevel?.format ?? \"decimal\");\n });\n}\n\n// ---------------------------------------------------------------------------\n// Default definitions (what the toolbar buttons create)\n\nexport const DEFAULT_BULLET_LIST_ID = \"bullets\";\nexport const DEFAULT_NUMBER_LIST_ID = \"numbers\";\nexport const DEFAULT_MULTILEVEL_LIST_ID = \"multilevel\";\n\nconst BULLETS = [\"\u2022\", \"\u25E6\", \"\u25AA\"];\nconst NUMBER_FORMATS: ListNumberFormat[] = [\"decimal\", \"lowerLetter\", \"lowerRoman\"];\n\nexport function defaultListDefinition(kind: \"bullet\" | \"decimal\"): ListDefinition {\n const levels: ListLevel[] = [];\n for (let i = 0; i < 9; i++) {\n if (kind === \"bullet\") {\n levels.push({\n format: \"bullet\",\n text: \"\",\n bulletChar: BULLETS[i % 3]!,\n indentLeftPx: 24 + i * 24,\n hangingPx: 18,\n start: 1,\n });\n } else {\n levels.push({\n format: NUMBER_FORMATS[i % 3]!,\n text: `%${i + 1}.`,\n indentLeftPx: 24 + i * 24,\n hangingPx: 22,\n start: 1,\n });\n }\n }\n return { id: kind === \"bullet\" ? DEFAULT_BULLET_LIST_ID : DEFAULT_NUMBER_LIST_ID, levels };\n}\n\n/** A single-glyph bullet list (every level uses `char`) \u2014 what the bullet\n * style picker applies. */\nexport function bulletListDefinition(id: string, char: string): ListDefinition {\n const levels: ListLevel[] = [];\n for (let i = 0; i < 9; i++) {\n levels.push({ format: \"bullet\", text: \"\", bulletChar: char, indentLeftPx: 24 + i * 24, hangingPx: 18, start: 1 });\n }\n return { id, levels };\n}\n\n/** A single-format numbered list (every level uses `format`), with `suffix`\n * after each counter (\".\" or \")\") \u2014 what the number style picker applies. */\nexport function numberListDefinition(id: string, format: ListNumberFormat, suffix: string): ListDefinition {\n const levels: ListLevel[] = [];\n for (let i = 0; i < 9; i++) {\n levels.push({ format, text: `%${i + 1}${suffix}`, indentLeftPx: 24 + i * 24, hangingPx: 22, start: 1 });\n }\n return { id, levels };\n}\n\n/** Legal-style multilevel list: every level is decimal and its marker compounds\n * all ancestors \u2014 level 0 \"1.\", level 1 \"1.1.\", level 2 \"1.1.1.\", \u2026 (Word's\n * default Multilevel List). Tab/Shift+Tab walk the levels. */\nexport function multilevelListDefinition(): ListDefinition {\n const levels: ListLevel[] = [];\n for (let i = 0; i < 9; i++) {\n const text = Array.from({ length: i + 1 }, (_, k) => `%${k + 1}`).join(\".\") + \".\";\n levels.push({\n format: \"decimal\",\n text, // \"%1.\", \"%1.%2.\", \"%1.%2.%3.\", \u2026\n indentLeftPx: 24 + i * 24,\n hangingPx: 24 + i * 8, // compound markers widen with depth\n start: 1,\n });\n }\n return { id: DEFAULT_MULTILEVEL_LIST_ID, levels };\n}\n", "// All mutation flows through operations. Applying an op returns the new document\n// (structurally shared), the op's exact INVERSE (undo is free), and a position\n// mapper so selections survive edits. This seam is also where OT/CRDT\n// collaboration would slot in later.\n\nimport type {\n BandContainer,\n Block,\n BookmarkRange,\n CharStyle,\n Document,\n ImageBlock,\n ParaStyle,\n Paragraph,\n Run,\n SdtProps,\n TableBlock,\n TableCell,\n TableRow,\n} from \"./document\";\nimport { BAND_CONTAINERS } from \"./document\";\nimport type { DocPosition } from \"./position\";\nimport {\n locateParagraph,\n paragraphAt,\n replaceParagraphAt,\n styleAtRuns,\n textOfRuns,\n type ParaLocation,\n} from \"./text\";\n\nexport type Op =\n | { type: \"insertText\"; at: DocPosition; text: string; style?: CharStyle }\n | { type: \"insertRuns\"; at: DocPosition; runs: Run[] }\n | { type: \"deleteRange\"; blockId: string; start: number; end: number }\n | { type: \"setRuns\"; blockId: string; runs: Run[] }\n | { type: \"setParaStyle\"; blockId: string; patch: Partial<ParaStyle> }\n | { type: \"splitParagraph\"; at: DocPosition; newBlockId: string; newStyle?: ParaStyle }\n | { type: \"mergeParagraphs\"; firstBlockId: string }\n | { type: \"insertBlock\"; index: number; block: Block; where?: Container }\n | { type: \"removeBlock\"; blockId: string }\n | { type: \"setImageProps\"; blockId: string; patch: Partial<Pick<ImageBlock, \"widthPx\" | \"heightPx\" | \"align\" | \"wrap\">> }\n | { type: \"setTableRow\"; tableId: string; rowIndex: number; row: TableRow }\n | { type: \"setTableStructure\"; tableId: string; rows: TableRow[]; colFractions?: number[] }\n | { type: \"setTableColFractions\"; blockId: string; fractions: number[] }\n | { type: \"insertTableRow\"; tableId: string; rowIndex: number; row: TableRow }\n | { type: \"removeTableRow\"; tableId: string; rowIndex: number }\n | { type: \"insertTableColumn\"; tableId: string; colIndex: number; cells: TableCell[]; fractions?: number[] }\n | { type: \"removeTableColumn\"; tableId: string; colIndex: number }\n | { type: \"setStylesheet\"; stylesheet: import(\"./stylesheet\").Stylesheet }\n | { type: \"setListDefinition\"; listId: string; def: import(\"./lists\").ListDefinition | null }\n | { type: \"setSectionProps\"; geometry: SectionGeometry }\n | { type: \"setSectionBand\"; band: BandContainer; blocks: Block[] | null }\n | { type: \"setFootnote\"; noteId: string; paras: Paragraph[] | null }\n | { type: \"setSdtProps\"; id: string; props: SdtProps | null }\n | { type: \"setBookmark\"; name: string; range: BookmarkRange | null };\n\n/** Page-setup fields of the final section (`doc.section`). Bands are NOT here \u2014\n * they change through container ops; mid-document sections change through\n * setParaStyle on their break paragraph's `sectionBreak.props`. */\nexport interface SectionGeometry {\n pageWidthPx: number;\n pageHeightPx: number;\n marginPx: { top: number; right: number; bottom: number; left: number };\n /** `null` = single column (the explicit \"off\" \u2014 SectionPatch distinguishes it\n * from \"inherit\"). */\n columns: { count: number; gapPx: number } | null;\n /** `null` = continue numbering from the previous section. */\n pageNumberStart: number | null;\n}\n\n/** Top-level block containers: the body, or one of the six margin-band stories\n * (default header/footer + first/even variants). */\nexport type Container = \"body\" | BandContainer;\n\nexport interface ApplyResult {\n doc: Document;\n inverse: Op;\n /** Remaps any stored position across this edit (selection, bookmarks). */\n mapPosition(pos: DocPosition): DocPosition;\n dirtyBlockIds: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Run-list surgery (pure helpers)\n\nexport function styleEq(a: CharStyle, b: CharStyle): boolean {\n return (\n a.fontFamily === b.fontFamily &&\n a.fontSizePx === b.fontSizePx &&\n a.bold === b.bold &&\n a.italic === b.italic &&\n a.underline === b.underline &&\n a.strikethrough === b.strikethrough &&\n a.color === b.color &&\n !!a.hidden === !!b.hidden && // hidden runs must never merge with visible ones\n (a.letterSpacingPx ?? 0) === (b.letterSpacingPx ?? 0) &&\n a.highlightColor === b.highlightColor &&\n a.verticalAlign === b.verticalAlign &&\n a.link === b.link &&\n a.footnoteRef === b.footnoteRef && // adjacent refs must never merge into one run\n a.sdtId === b.sdtId // content-control boundaries survive normalization\n );\n}\n\n/** Merge equal-styled neighbors, drop empties. An all-empty paragraph keeps ONE\n * empty run so the paragraph mark still carries a style (Word behavior). */\nexport function normalizeRuns(runs: Run[], fallback: CharStyle): Run[] {\n const nonEmpty = runs.filter((r) => r.text.length > 0);\n if (nonEmpty.length === 0) return [{ text: \"\", style: runs[0]?.style ?? fallback }];\n const out: Run[] = [{ ...nonEmpty[0]! }];\n for (let i = 1; i < nonEmpty.length; i++) {\n const r = nonEmpty[i]!;\n const last = out[out.length - 1]!;\n if (styleEq(last.style, r.style)) last.text += r.text;\n else out.push({ ...r });\n }\n return out;\n}\n\n/** Split a run list at a UTF-16 offset. */\nexport function splitRunsAt(runs: Run[], offset: number): [Run[], Run[]] {\n const head: Run[] = [];\n const tail: Run[] = [];\n let cum = 0;\n for (const r of runs) {\n const end = cum + r.text.length;\n if (end <= offset) head.push(r);\n else if (cum >= offset) tail.push(r);\n else {\n head.push({ text: r.text.slice(0, offset - cum), style: r.style });\n tail.push({ text: r.text.slice(offset - cum), style: r.style });\n }\n cum = end;\n }\n return [head, tail];\n}\n\nexport function sliceRuns(runs: Run[], start: number, end: number): Run[] {\n const [, fromStart] = splitRunsAt(runs, start);\n const [middle] = splitRunsAt(fromStart, end - start);\n return middle;\n}\n\nfunction fallbackStyle(runs: Run[], offset: number): CharStyle {\n return (\n styleAtRuns(runs, offset) ?? {\n fontFamily: \"Georgia, serif\",\n fontSizePx: 16,\n bold: false,\n italic: false,\n underline: false,\n strikethrough: false,\n color: \"#202124\",\n }\n );\n}\n\nexport function insertTextInRuns(runs: Run[], offset: number, text: string, style?: CharStyle): Run[] {\n const st = style ?? fallbackStyle(runs, offset);\n const [head, tail] = splitRunsAt(runs, offset);\n return normalizeRuns([...head, { text, style: st }, ...tail], st);\n}\n\nexport function insertRunsInRuns(runs: Run[], offset: number, inserted: Run[]): Run[] {\n const [head, tail] = splitRunsAt(runs, offset);\n return normalizeRuns([...head, ...inserted, ...tail], fallbackStyle(runs, offset));\n}\n\nexport function deleteInRuns(runs: Run[], start: number, end: number): Run[] {\n const [head, fromStart] = splitRunsAt(runs, start);\n const [, tail] = splitRunsAt(fromStart, end - start);\n return normalizeRuns([...head, ...tail], fallbackStyle(runs, start));\n}\n\nexport function applyStylePatchToRuns(\n runs: Run[],\n start: number,\n end: number,\n patch: Partial<CharStyle>,\n): Run[] {\n const [head, fromStart] = splitRunsAt(runs, start);\n const [middle, tail] = splitRunsAt(fromStart, end - start);\n const styled = middle.map((r) => ({ text: r.text, style: { ...r.style, ...patch } }));\n return normalizeRuns([...head, ...styled, ...tail], fallbackStyle(runs, start));\n}\n\n/** Rewrite the TEXT of runs in [start,end) through `fn`, preserving each run's\n * style. `fn` receives the whole covered string (so it can do context-aware\n * transforms like sentence case) and must return a string of the SAME length \u2014\n * if the length changes the slice is left untouched (run offsets must stay\n * stable). Used by change-case; transforms here are 1:1 per character. */\nexport function mapTextInRuns(\n runs: Run[],\n start: number,\n end: number,\n fn: (covered: string) => string,\n): Run[] {\n const [head, fromStart] = splitRunsAt(runs, start);\n const [middle, tail] = splitRunsAt(fromStart, end - start);\n const original = middle.map((r) => r.text).join(\"\");\n const next = fn(original);\n let i = 0;\n const out =\n next.length === original.length\n ? middle.map((r) => {\n const text = next.slice(i, i + r.text.length);\n i += r.text.length;\n return { text, style: r.style };\n })\n : middle;\n return normalizeRuns([...head, ...out, ...tail], fallbackStyle(runs, start));\n}\n\n// ---------------------------------------------------------------------------\n// applyOp\n\nconst identity = (p: DocPosition): DocPosition => p;\n\n/** Content ops accept ANY paragraph (top-level or table cell). */\nfunction mustLocate(doc: Document, blockId: string): { loc: ParaLocation; block: Paragraph } {\n const loc = locateParagraph(doc, blockId);\n if (!loc) throw new Error(`paragraph ${blockId} not found`);\n return { loc, block: paragraphAt(doc, loc) };\n}\n\n// ---- container plumbing: structural ops work on the body OR a band story ----\n\nexport function containerBlocks(doc: Document, where: Container): Block[] {\n return where === \"body\" ? doc.blocks : (doc.section[where] ?? []);\n}\n\nfunction withContainerBlocks(doc: Document, where: Container, blocks: Block[]): Document {\n if (where === \"body\") return { ...doc, blocks };\n return { ...doc, section: { ...doc.section, [where]: blocks } };\n}\n\n/** Which container holds this TOP-LEVEL block (paragraphs in table cells are\n * not top-level and return null). */\nexport function containerOf(doc: Document, blockId: string): { where: Container; index: number } | null {\n for (const where of [\"body\", ...BAND_CONTAINERS] as Container[]) {\n const index = containerBlocks(doc, where).findIndex((b) => b.id === blockId);\n if (index >= 0) return { where, index };\n }\n return null;\n}\n\nfunction mustTable(doc: Document, blockId: string): { where: Container; bi: number; block: TableBlock } {\n const found = containerOf(doc, blockId);\n const block = found ? containerBlocks(doc, found.where)[found.index] : undefined;\n if (!found || !block || block.kind !== \"table\") throw new Error(`table ${blockId} not found`);\n return { where: found.where, bi: found.index, block };\n}\n\nfunction replaceTable(doc: Document, where: Container, bi: number, table: TableBlock): Document {\n const blocks = containerBlocks(doc, where).slice();\n blocks[bi] = { ...table, revision: table.revision + 1 };\n return withContainerBlocks(doc, where, blocks);\n}\n\n/** Path-clone a table (body or band) replacing one cell's block list\n * (revision bumped). */\nfunction replaceCellBlocks(\n doc: Document,\n where: Container,\n bi: number,\n ri: number,\n ci: number,\n cellBlocks: Block[],\n): Document {\n const blocks = containerBlocks(doc, where).slice();\n const table = blocks[bi] as TableBlock;\n const rows = table.rows.slice();\n const row = { cells: rows[ri]!.cells.slice() };\n row.cells[ci] = { ...row.cells[ci]!, blocks: cellBlocks };\n rows[ri] = row;\n blocks[bi] = { ...table, rows, revision: table.revision + 1 };\n return withContainerBlocks(doc, where, blocks);\n}\n\n/** First caret-capable paragraph in a set of rows (cells may start with images). */\nexport function firstParagraphInRows(rows: TableRow[]): Paragraph | undefined {\n for (const row of rows) {\n for (const cell of row.cells) {\n for (const b of cell.blocks) if (b.kind === \"paragraph\") return b;\n }\n }\n return undefined;\n}\n\n/** Find an image block anywhere editable: top-level containers or table cells. */\nexport type ImageLocation =\n | { kind: \"top\"; where: Container; index: number; image: ImageBlock }\n | { kind: \"cell\"; bi: number; ri: number; ci: number; ii: number; image: ImageBlock };\n\nexport function locateImage(doc: Document, blockId: string): ImageLocation | null {\n const found = containerOf(doc, blockId);\n if (found) {\n const block = containerBlocks(doc, found.where)[found.index];\n if (block?.kind === \"image\") return { kind: \"top\", where: found.where, index: found.index, image: block };\n return null;\n }\n for (let bi = 0; bi < doc.blocks.length; bi++) {\n const b = doc.blocks[bi]!;\n if (b.kind !== \"table\") continue;\n for (let ri = 0; ri < b.rows.length; ri++) {\n const row = b.rows[ri]!;\n for (let ci = 0; ci < row.cells.length; ci++) {\n const cell = row.cells[ci]!;\n for (let ii = 0; ii < cell.blocks.length; ii++) {\n const cb = cell.blocks[ii]!;\n if (cb.kind === \"image\" && cb.id === blockId) return { kind: \"cell\", bi, ri, ci, ii, image: cb };\n }\n }\n }\n }\n return null;\n}\n\n/** The table's true grid-column count: the widest row measured in GRID columns,\n * not cells \u2014 colSpan widens a cell and a rowSpan from an earlier row leaves a\n * hole that this row's cells shift past (HTML table model). Counting cells\n * alone undercounts any row with a colSpan or a vertical-merge hole. */\nexport function gridColumnCount(t: TableBlock): number {\n let maxCols = 1;\n const rowsRemaining: number[] = [];\n for (const row of t.rows) {\n let col = 0;\n for (const cell of row.cells) {\n while ((rowsRemaining[col] ?? 0) > 0) col++;\n const span = Math.max(1, cell.colSpan ?? 1);\n const rowSpan = Math.max(1, cell.rowSpan ?? 1);\n if (rowSpan > 1) for (let k = 0; k < span; k++) rowsRemaining[col + k] = rowSpan;\n col += span;\n }\n maxCols = Math.max(maxCols, col);\n for (let c = 0; c < rowsRemaining.length; c++) if (rowsRemaining[c]! > 0) rowsRemaining[c]!--;\n }\n return maxCols;\n}\n\n/** Column fractions normalized to the table's column count. */\nexport function effectiveFractions(t: TableBlock): number[] {\n const n = gridColumnCount(t);\n if (t.colFractions && t.colFractions.length === n) return t.colFractions;\n return Array.from({ length: n }, () => 1 / n);\n}\n\nfunction bump(block: Paragraph, runs: Run[]): Paragraph {\n return { ...block, runs, revision: block.revision + 1 };\n}\n\nexport function applyOp(doc: Document, op: Op): ApplyResult {\n switch (op.type) {\n case \"insertText\": {\n const { loc, block } = mustLocate(doc, op.at.blockId);\n const runs = insertTextInRuns(block.runs, op.at.offset, op.text, op.style);\n const len = op.text.length;\n return {\n doc: replaceParagraphAt(doc, loc, bump(block, runs)),\n inverse: { type: \"deleteRange\", blockId: block.id, start: op.at.offset, end: op.at.offset + len },\n mapPosition: (p) =>\n p.blockId === block.id && p.offset >= op.at.offset\n ? { blockId: p.blockId, offset: p.offset + len }\n : p,\n dirtyBlockIds: [block.id],\n };\n }\n\n case \"insertRuns\": {\n const { loc, block } = mustLocate(doc, op.at.blockId);\n const len = textOfRuns(op.runs).length;\n const runs = insertRunsInRuns(block.runs, op.at.offset, op.runs);\n return {\n doc: replaceParagraphAt(doc, loc, bump(block, runs)),\n inverse: { type: \"deleteRange\", blockId: block.id, start: op.at.offset, end: op.at.offset + len },\n mapPosition: (p) =>\n p.blockId === block.id && p.offset >= op.at.offset\n ? { blockId: p.blockId, offset: p.offset + len }\n : p,\n dirtyBlockIds: [block.id],\n };\n }\n\n case \"deleteRange\": {\n const { loc, block } = mustLocate(doc, op.blockId);\n const removed = sliceRuns(block.runs, op.start, op.end);\n const runs = deleteInRuns(block.runs, op.start, op.end);\n const len = op.end - op.start;\n return {\n doc: replaceParagraphAt(doc, loc, bump(block, runs)),\n inverse: { type: \"insertRuns\", at: { blockId: block.id, offset: op.start }, runs: removed },\n mapPosition: (p) => {\n if (p.blockId !== block.id || p.offset <= op.start) return p;\n return { blockId: p.blockId, offset: p.offset >= op.end ? p.offset - len : op.start };\n },\n dirtyBlockIds: [block.id],\n };\n }\n\n case \"setRuns\": {\n const { loc, block } = mustLocate(doc, op.blockId);\n return {\n doc: replaceParagraphAt(doc, loc, bump(block, op.runs)),\n inverse: { type: \"setRuns\", blockId: block.id, runs: block.runs },\n mapPosition: identity, // callers use setRuns for length-preserving restyles\n dirtyBlockIds: [block.id],\n };\n }\n\n case \"setParaStyle\": {\n const { loc, block } = mustLocate(doc, op.blockId);\n const oldPatch: Partial<ParaStyle> = {};\n for (const key of Object.keys(op.patch) as (keyof ParaStyle)[]) {\n // @ts-expect-error \u2014 keyed copy of the previous values for the inverse\n oldPatch[key] = block.style[key];\n }\n const styled: Paragraph = {\n ...block,\n style: { ...block.style, ...op.patch },\n revision: block.revision + 1,\n };\n return {\n doc: replaceParagraphAt(doc, loc, styled),\n inverse: { type: \"setParaStyle\", blockId: block.id, patch: oldPatch },\n mapPosition: identity,\n dirtyBlockIds: [block.id],\n };\n }\n\n case \"splitParagraph\": {\n const loc = locateParagraph(doc, op.at.blockId);\n if (!loc) throw new Error(`paragraph ${op.at.blockId} not found`);\n const block = paragraphAt(doc, loc);\n const [headRuns, tailRuns] = splitRunsAt(block.runs, op.at.offset);\n const carry = fallbackStyle(block.runs, op.at.offset);\n const head = bump(block, normalizeRuns(headRuns, carry));\n const tail: Paragraph = {\n kind: \"paragraph\",\n id: op.newBlockId,\n revision: 0,\n runs: normalizeRuns(tailRuns, carry),\n style: op.newStyle ?? { ...block.style },\n };\n let next: Document;\n if (loc.kind === \"cell\") {\n const table = containerBlocks(doc, loc.where)[loc.bi] as TableBlock;\n const cellBlocks = table.rows[loc.ri]!.cells[loc.ci]!.blocks.slice();\n cellBlocks.splice(loc.pi, 1, head, tail);\n next = replaceCellBlocks(doc, loc.where, loc.bi, loc.ri, loc.ci, cellBlocks);\n } else if (loc.kind === \"footnote\") {\n const paras = doc.footnotes![loc.noteId]!.slice();\n paras.splice(loc.pi, 1, head, tail);\n next = { ...doc, footnotes: { ...doc.footnotes, [loc.noteId]: paras } };\n } else {\n const where: Container = loc.kind === \"band\" ? loc.band : \"body\";\n const blocks = containerBlocks(doc, where).slice();\n blocks.splice(loc.bi, 1, head, tail);\n next = withContainerBlocks(doc, where, blocks);\n }\n return {\n doc: next,\n inverse: { type: \"mergeParagraphs\", firstBlockId: block.id },\n mapPosition: (p) =>\n p.blockId === block.id && p.offset >= op.at.offset\n ? { blockId: op.newBlockId, offset: p.offset - op.at.offset }\n : p,\n dirtyBlockIds: [block.id, op.newBlockId],\n };\n }\n\n case \"mergeParagraphs\": {\n const loc = locateParagraph(doc, op.firstBlockId);\n if (!loc) throw new Error(`paragraph ${op.firstBlockId} not found`);\n const block = paragraphAt(doc, loc);\n let nextPara: Paragraph;\n if (loc.kind === \"cell\") {\n const table = containerBlocks(doc, loc.where)[loc.bi] as TableBlock;\n const candidate = table.rows[loc.ri]!.cells[loc.ci]!.blocks[loc.pi + 1];\n if (!candidate || candidate.kind !== \"paragraph\") {\n throw new Error(\"mergeParagraphs: no next paragraph in cell\");\n }\n nextPara = candidate;\n } else if (loc.kind === \"footnote\") {\n const candidate = doc.footnotes![loc.noteId]![loc.pi + 1];\n if (!candidate) throw new Error(\"mergeParagraphs: no next paragraph in footnote\");\n nextPara = candidate;\n } else {\n const where: Container = loc.kind === \"band\" ? loc.band : \"body\";\n const candidate = containerBlocks(doc, where)[loc.bi + 1];\n if (!candidate || candidate.kind !== \"paragraph\") {\n throw new Error(\"mergeParagraphs: no next paragraph\");\n }\n nextPara = candidate;\n }\n const headLen = textOfRuns(block.runs).length;\n const merged = bump(\n block,\n normalizeRuns([...block.runs, ...nextPara.runs], fallbackStyle(block.runs, headLen)),\n );\n let next: Document;\n if (loc.kind === \"cell\") {\n const table = containerBlocks(doc, loc.where)[loc.bi] as TableBlock;\n const cellBlocks = table.rows[loc.ri]!.cells[loc.ci]!.blocks.slice();\n cellBlocks.splice(loc.pi, 2, merged);\n next = replaceCellBlocks(doc, loc.where, loc.bi, loc.ri, loc.ci, cellBlocks);\n } else if (loc.kind === \"footnote\") {\n const paras = doc.footnotes![loc.noteId]!.slice();\n paras.splice(loc.pi, 2, merged);\n next = { ...doc, footnotes: { ...doc.footnotes, [loc.noteId]: paras } };\n } else {\n const where: Container = loc.kind === \"band\" ? loc.band : \"body\";\n const blocks = containerBlocks(doc, where).slice();\n blocks.splice(loc.bi, 2, merged);\n next = withContainerBlocks(doc, where, blocks);\n }\n return {\n doc: next,\n inverse: {\n type: \"splitParagraph\",\n at: { blockId: block.id, offset: headLen },\n newBlockId: nextPara.id,\n newStyle: nextPara.style,\n },\n mapPosition: (p) =>\n p.blockId === nextPara.id ? { blockId: block.id, offset: headLen + p.offset } : p,\n dirtyBlockIds: [block.id, nextPara.id],\n };\n }\n\n case \"insertBlock\": {\n const where = op.where ?? \"body\";\n const blocks = containerBlocks(doc, where).slice();\n blocks.splice(op.index, 0, op.block);\n return {\n doc: withContainerBlocks(doc, where, blocks),\n inverse: { type: \"removeBlock\", blockId: op.block.id },\n mapPosition: identity,\n dirtyBlockIds: [op.block.id],\n };\n }\n\n case \"removeBlock\": {\n const found = containerOf(doc, op.blockId);\n if (!found) throw new Error(`block ${op.blockId} not found`);\n const blocks = containerBlocks(doc, found.where).slice();\n const block = blocks[found.index]!;\n blocks.splice(found.index, 1);\n const neighbor = blocks[Math.min(found.index, blocks.length - 1)];\n return {\n doc: withContainerBlocks(doc, found.where, blocks),\n inverse: { type: \"insertBlock\", index: found.index, block, where: found.where },\n mapPosition: (p) =>\n p.blockId === op.blockId && neighbor\n ? { blockId: neighbor.id, offset: 0 }\n : p,\n dirtyBlockIds: [op.blockId],\n };\n }\n\n case \"setImageProps\": {\n const loc = locateImage(doc, op.blockId);\n if (!loc) throw new Error(`image ${op.blockId} not found`);\n const block = loc.image;\n const oldPatch: typeof op.patch = {};\n for (const key of Object.keys(op.patch) as (keyof typeof op.patch)[]) {\n // @ts-expect-error \u2014 keyed copy of previous values for the inverse\n oldPatch[key] = block[key];\n }\n const updated: ImageBlock = { ...block, ...op.patch, revision: block.revision + 1 };\n let next: Document;\n if (loc.kind === \"top\") {\n const blocks = containerBlocks(doc, loc.where).slice();\n blocks[loc.index] = updated;\n next = withContainerBlocks(doc, loc.where, blocks);\n } else {\n const table = doc.blocks[loc.bi] as TableBlock;\n const cellBlocks = table.rows[loc.ri]!.cells[loc.ci]!.blocks.slice();\n cellBlocks[loc.ii] = updated;\n next = replaceCellBlocks(doc, \"body\", loc.bi, loc.ri, loc.ci, cellBlocks); // locateImage cells are body-only\n\n }\n return {\n doc: next,\n inverse: { type: \"setImageProps\", blockId: op.blockId, patch: oldPatch },\n mapPosition: identity,\n dirtyBlockIds: [op.blockId],\n };\n }\n\n case \"setTableColFractions\": {\n const { where, bi, block } = mustTable(doc, op.blockId);\n const old = effectiveFractions(block);\n return {\n doc: replaceTable(doc, where, bi, { ...block, colFractions: op.fractions }),\n inverse: { type: \"setTableColFractions\", blockId: op.blockId, fractions: old },\n mapPosition: identity,\n dirtyBlockIds: [op.blockId],\n };\n }\n\n case \"setTableRow\": {\n const { where, bi, block } = mustTable(doc, op.tableId);\n const old = block.rows[op.rowIndex];\n if (!old) throw new Error(\"setTableRow: no such row\");\n const removedIds = new Set(old.cells.flatMap((c) => c.blocks.map((p) => p.id)));\n const rows = block.rows.slice();\n rows[op.rowIndex] = op.row;\n const fallback = firstParagraphInRows([op.row]);\n return {\n doc: replaceTable(doc, where, bi, { ...block, rows }),\n inverse: { type: \"setTableRow\", tableId: op.tableId, rowIndex: op.rowIndex, row: old },\n mapPosition: (p) => {\n if (!removedIds.has(p.blockId)) return p;\n // position survives if its paragraph still exists in the new row\n const kept = op.row.cells.some((c) => c.blocks.some((b) => b.id === p.blockId));\n return kept || !fallback ? p : { blockId: fallback.id, offset: 0 };\n },\n dirtyBlockIds: [op.tableId],\n };\n }\n\n case \"insertTableRow\": {\n const { where, bi, block } = mustTable(doc, op.tableId);\n const rows = block.rows.slice();\n rows.splice(op.rowIndex, 0, op.row);\n return {\n doc: replaceTable(doc, where, bi, { ...block, rows }),\n inverse: { type: \"removeTableRow\", tableId: op.tableId, rowIndex: op.rowIndex },\n mapPosition: identity,\n dirtyBlockIds: [op.tableId],\n };\n }\n\n case \"removeTableRow\": {\n const { where, bi, block } = mustTable(doc, op.tableId);\n const removed = block.rows[op.rowIndex];\n if (!removed) throw new Error(\"removeTableRow: no such row\");\n const rows = block.rows.slice();\n rows.splice(op.rowIndex, 1);\n const removedIds = new Set(removed.cells.flatMap((c) => c.blocks.map((p) => p.id)));\n const fallbackRow = rows[Math.min(op.rowIndex, rows.length - 1)];\n const fallback = fallbackRow ? firstParagraphInRows([fallbackRow]) : undefined;\n return {\n doc: replaceTable(doc, where, bi, { ...block, rows }),\n inverse: { type: \"insertTableRow\", tableId: op.tableId, rowIndex: op.rowIndex, row: removed },\n mapPosition: (p) =>\n removedIds.has(p.blockId) && fallback ? { blockId: fallback.id, offset: 0 } : p,\n dirtyBlockIds: [op.tableId],\n };\n }\n\n case \"setListDefinition\": {\n const old = doc.lists?.[op.listId] ?? null;\n const lists = { ...(doc.lists ?? {}) };\n if (op.def) lists[op.listId] = op.def;\n else delete lists[op.listId];\n return {\n doc: { ...doc, lists },\n inverse: { type: \"setListDefinition\", listId: op.listId, def: old },\n mapPosition: identity,\n // Indents come from the definition \u2192 every paragraph in the list must\n // re-measure; their (revision,width) line-cache keys change with width.\n dirtyBlockIds: [],\n };\n }\n\n case \"setTableStructure\": {\n const { where, bi, block } = mustTable(doc, op.tableId);\n const oldIds = new Set(\n block.rows.flatMap((r) => r.cells.flatMap((c) => c.blocks.map((p) => p.id))),\n );\n const fallback = firstParagraphInRows(op.rows);\n const next: TableBlock = { ...block, rows: op.rows };\n if (op.colFractions) next.colFractions = op.colFractions;\n else delete next.colFractions;\n const inverse: Op = { type: \"setTableStructure\", tableId: op.tableId, rows: block.rows };\n if (block.colFractions) inverse.colFractions = block.colFractions;\n return {\n doc: replaceTable(doc, where, bi, next),\n inverse,\n mapPosition: (p) => {\n if (!oldIds.has(p.blockId)) return p;\n const kept = op.rows.some((r) =>\n r.cells.some((c) => c.blocks.some((b) => b.id === p.blockId)),\n );\n return kept || !fallback ? p : { blockId: fallback.id, offset: 0 };\n },\n dirtyBlockIds: [op.tableId],\n };\n }\n\n case \"insertTableColumn\": {\n const { where, bi, block } = mustTable(doc, op.tableId);\n const snapshot: Op = { type: \"setTableStructure\", tableId: op.tableId, rows: block.rows };\n if (block.colFractions) snapshot.colFractions = block.colFractions;\n // Span-aware: a merged cell covering the insertion point grows by one\n // column instead of having a new cell slotted into its middle.\n const rows = block.rows.map((row, ri) => {\n const cells: TableCell[] = [];\n let col = 0;\n let inserted = false;\n for (const cell of row.cells) {\n const span = cell.colSpan ?? 1;\n if (!inserted && op.colIndex > col && op.colIndex < col + span) {\n cells.push({ ...cell, colSpan: span + 1 });\n inserted = true;\n } else if (!inserted && op.colIndex === col) {\n const fresh = op.cells[ri];\n if (fresh) cells.push(fresh);\n cells.push(cell);\n inserted = true;\n } else {\n cells.push(cell);\n }\n col += span;\n }\n if (!inserted) {\n const fresh = op.cells[ri];\n if (fresh) cells.push(fresh); // append at the right edge\n }\n return { cells };\n });\n let fractions = op.fractions;\n if (!fractions) {\n const old = effectiveFractions(block);\n const fresh = 1 / (old.length + 1);\n fractions = old.map((f) => f * (1 - fresh));\n fractions.splice(op.colIndex, 0, fresh);\n }\n return {\n doc: replaceTable(doc, where, bi, { ...block, rows, colFractions: fractions }),\n inverse: snapshot,\n mapPosition: identity,\n dirtyBlockIds: [op.tableId],\n };\n }\n\n case \"setStylesheet\": {\n const old = doc.stylesheet ?? { styles: [], defaultStyleId: \"Normal\" };\n return {\n doc: { ...doc, stylesheet: op.stylesheet },\n inverse: { type: \"setStylesheet\", stylesheet: old },\n mapPosition: identity,\n dirtyBlockIds: [], // restyling paragraphs happens via setRuns/setParaStyle ops\n };\n }\n\n case \"setSectionProps\": {\n const s = doc.section;\n const old: SectionGeometry = {\n pageWidthPx: s.pageWidthPx,\n pageHeightPx: s.pageHeightPx,\n marginPx: { ...s.marginPx },\n columns: s.columns ? { ...s.columns } : null,\n pageNumberStart: s.pageNumberStart ?? null,\n };\n const next = {\n ...s,\n pageWidthPx: op.geometry.pageWidthPx,\n pageHeightPx: op.geometry.pageHeightPx,\n marginPx: op.geometry.marginPx,\n };\n if (op.geometry.columns) next.columns = op.geometry.columns;\n else delete next.columns;\n if (op.geometry.pageNumberStart !== null) next.pageNumberStart = op.geometry.pageNumberStart;\n else delete next.pageNumberStart;\n return {\n doc: { ...doc, section: next },\n inverse: { type: \"setSectionProps\", geometry: old },\n mapPosition: identity,\n dirtyBlockIds: [], // geometry change \u2192 full re-walk; line caches keyed by width\n };\n }\n\n case \"setFootnote\": {\n const old = doc.footnotes?.[op.noteId] ?? null;\n const footnotes = { ...(doc.footnotes ?? {}) };\n if (op.paras) footnotes[op.noteId] = op.paras;\n else delete footnotes[op.noteId];\n const removedIds = new Set((old ?? []).map((p) => p.id));\n const fallback = doc.blocks.find((b) => b.kind === \"paragraph\");\n return {\n doc: { ...doc, footnotes },\n inverse: { type: \"setFootnote\", noteId: op.noteId, paras: old },\n mapPosition: (p) => {\n if (!removedIds.has(p.blockId)) return p;\n const kept = (op.paras ?? []).some((b) => b.id === p.blockId);\n return kept || !fallback ? p : { blockId: fallback.id, offset: 0 };\n },\n dirtyBlockIds: [],\n };\n }\n\n case \"setSdtProps\": {\n const old = doc.sdts?.[op.id] ?? null;\n const sdts = { ...(doc.sdts ?? {}) };\n if (op.props) sdts[op.id] = op.props;\n else delete sdts[op.id];\n return {\n doc: { ...doc, sdts },\n inverse: { type: \"setSdtProps\", id: op.id, props: old },\n mapPosition: identity,\n dirtyBlockIds: [], // run markers change via setRuns/applyStylePatch ops\n };\n }\n\n case \"setBookmark\": {\n const old = doc.bookmarks?.[op.name] ?? null;\n const bookmarks = { ...(doc.bookmarks ?? {}) };\n if (op.range) bookmarks[op.name] = op.range;\n else delete bookmarks[op.name];\n return {\n doc: { ...doc, bookmarks },\n inverse: { type: \"setBookmark\", name: op.name, range: old },\n mapPosition: identity,\n dirtyBlockIds: [],\n };\n }\n\n case \"setSectionBand\": {\n const old = doc.section[op.band] ?? null;\n const section = { ...doc.section };\n if (op.blocks) section[op.band] = op.blocks;\n else delete section[op.band];\n const removedIds = new Set(\n (old ?? []).filter((b) => b.kind === \"paragraph\").map((b) => b.id),\n );\n const fallback = doc.blocks.find((b) => b.kind === \"paragraph\");\n return {\n doc: { ...doc, section },\n inverse: { type: \"setSectionBand\", band: op.band, blocks: old },\n mapPosition: (p) => {\n if (!removedIds.has(p.blockId)) return p;\n const kept = (op.blocks ?? []).some((b) => b.id === p.blockId);\n return kept || !fallback ? p : { blockId: fallback.id, offset: 0 };\n },\n dirtyBlockIds: [],\n };\n }\n\n case \"removeTableColumn\": {\n const { where, bi, block } = mustTable(doc, op.tableId);\n const snapshot: Op = { type: \"setTableStructure\", tableId: op.tableId, rows: block.rows };\n if (block.colFractions) snapshot.colFractions = block.colFractions;\n const oldFractions = effectiveFractions(block);\n const removedIds = new Set<string>();\n // Span-aware: a merged cell covering the removed column shrinks; an\n // unmerged cell at that column is dropped.\n const rows = block.rows.map((row) => {\n const cells: TableCell[] = [];\n let col = 0;\n for (const cell of row.cells) {\n const span = cell.colSpan ?? 1;\n if (op.colIndex >= col && op.colIndex < col + span) {\n if (span > 1) {\n const shrunk: TableCell = { ...cell };\n if (span - 1 > 1) shrunk.colSpan = span - 1;\n else delete shrunk.colSpan;\n cells.push(shrunk);\n } else {\n for (const p of cell.blocks) removedIds.add(p.id);\n }\n } else {\n cells.push(cell);\n }\n col += span;\n }\n return { cells };\n });\n const rest = oldFractions.filter((_, i) => i !== op.colIndex);\n const sum = rest.reduce((s, f) => s + f, 0) || 1;\n const fractions = rest.map((f) => f / sum);\n const fallback = firstParagraphInRows(rows);\n return {\n doc: replaceTable(doc, where, bi, { ...block, rows, colFractions: fractions }),\n inverse: snapshot,\n mapPosition: (p) =>\n removedIds.has(p.blockId) && fallback ? { blockId: fallback.id, offset: 0 } : p,\n dirtyBlockIds: [op.tableId],\n };\n }\n }\n}\n", "// Table-of-contents field instruction parsing + generation options. Pure and\n// layout-free, so import, export, and the backend route all share it.\n//\n// The OOXML `TOC` field instruction (e.g. ` TOC \\o \"1-3\" \\h \\z \\u `) governs WHICH\n// paragraphs become entries and some behaviors; the visual leader/right-tab live in\n// the TOC paragraph styles, not the instruction (see TocOptions).\n\nimport type { CharStyle, Document, ParaStyle, Paragraph } from \"./model/document\";\nimport { bodyParagraphs, textOfRuns } from \"./model/text\";\nimport { sliceRuns } from \"./model/ops\";\n\n/** Parsed `TOC` field switches. See ECMA-376 \u00A717.16.5.68. */\nexport interface TocSwitches {\n /** \\o \"1-3\" \u2014 include built-in heading levels in this (inclusive) range. */\n outlineRange?: { from: number; to: number };\n /** \\u \u2014 also include paragraphs with a directly-applied outline level. */\n useOutlineLevels: boolean;\n /** \\t \"StyleName,level,\u2026\" \u2014 custom paragraph styles mapped to TOC levels. */\n customStyles?: Record<string, number>;\n /** \\h \u2014 emit each entry as a hyperlink to its heading. */\n hyperlinks: boolean;\n /** \\n \"1-3\" \u2014 omit page numbers for levels in this (inclusive) range. */\n hidePageNumberRange?: { from: number; to: number };\n /** \\p \"sep\" \u2014 text between the entry label and the page number (default tab). */\n separator?: string;\n /** \\z \u2014 hide tab/leader/page numbers in Web layout view (display-only). */\n hideInWeb: boolean;\n}\n\nconst rangeOf = (s: string | undefined): { from: number; to: number } | undefined => {\n if (!s) return undefined;\n const m = s.match(/^(\\d+)\\s*-\\s*(\\d+)$/);\n if (m) return { from: Number(m[1]), to: Number(m[2]) };\n const n = s.match(/^\\d+$/) ? Number(s) : NaN;\n return Number.isNaN(n) ? undefined : { from: n, to: n };\n};\n\n/** Parse a `TOC` field instruction string into its switches. */\nexport function parseTocInstruction(instr: string): TocSwitches {\n const sw: TocSwitches = {\n useOutlineLevels: /\\\\u\\b/.test(instr),\n hyperlinks: /\\\\h\\b/.test(instr),\n hideInWeb: /\\\\z\\b/.test(instr),\n };\n const oRange = rangeOf(instr.match(/\\\\o\\s+\"([^\"]+)\"/)?.[1]);\n if (oRange) sw.outlineRange = oRange;\n const nRange = rangeOf(instr.match(/\\\\n\\s+\"?([0-9-]+)\"?/)?.[1]);\n if (nRange) sw.hidePageNumberRange = nRange;\n const sep = instr.match(/\\\\p\\s+\"([^\"]*)\"/)?.[1];\n if (sep !== undefined) sw.separator = sep;\n const t = instr.match(/\\\\t\\s+\"([^\"]+)\"/)?.[1];\n if (t) {\n const pairs = t.split(\",\");\n const map: Record<string, number> = {};\n for (let i = 0; i + 1 < pairs.length; i += 2) {\n const name = pairs[i]!.trim();\n const lvl = Number(pairs[i + 1]!.trim());\n if (name && lvl >= 1) map[name] = lvl;\n }\n if (Object.keys(map).length > 0) sw.customStyles = map;\n }\n return sw;\n}\n\n/** Is `level` covered by an inclusive range (undefined range = no). */\nexport const inRange = (r: { from: number; to: number } | undefined, level: number): boolean =>\n !!r && level >= r.from && level <= r.to;\n\n// ---------------------------------------------------------------------------\n// Heading detection (shared by the editor's TOC generation and the headless\n// route) \u2014 robust to opaque style ids: tries the effective outline level, then\n// the resolved style NAME (\"Heading 1\"), then a `namedStyle` like \"heading1\",\n// then \\t custom-style mappings.\n\n/** The friendly name of a style id, following basedOn (for opaque numeric ids). */\nfunction styleNameOf(doc: Document, id: string | undefined): string | undefined {\n if (!id || !doc.stylesheet) return undefined;\n const byId = new Map(doc.stylesheet.styles.map((s) => [s.id, s]));\n let cur = byId.get(id);\n const seen = new Set<string>();\n while (cur && !seen.has(cur.id)) {\n if (cur.name && /(?:^|\\s)heading\\s*\\d/i.test(cur.name)) return cur.name;\n seen.add(cur.id);\n cur = cur.basedOn ? byId.get(cur.basedOn) : undefined;\n }\n return byId.get(id)?.name;\n}\n\n/** TOC level (1-based) for a paragraph, or null if it's not a heading. */\nexport function tocHeadingLevel(p: Paragraph, doc: Document, sw?: TocSwitches): number | null {\n const named = p.style.namedStyle;\n // \\t custom styles (match by opaque id or by friendly name).\n if (sw?.customStyles && named) {\n const lv = sw.customStyles[named] ?? sw.customStyles[styleNameOf(doc, named) ?? \"\"];\n if (lv) return lv;\n }\n if (p.style.outlineLevel !== undefined) return p.style.outlineLevel + 1;\n const direct = named?.match(/^heading\\s*(\\d)$/i);\n if (direct) return Number(direct[1]);\n const m = styleNameOf(doc, named)?.match(/(?:^|\\s)heading\\s*(\\d)/i);\n return m ? Number(m[1]) : null;\n}\n\n/** All TOC-eligible headings in document order (body + table cells), honoring the\n * switches' `\\o` range (when present) and `maxLevel`. */\nexport function detectTocHeadings(\n doc: Document,\n sw?: TocSwitches,\n maxLevel = 3,\n): { block: Paragraph; level: number }[] {\n const cap = sw?.outlineRange?.to ?? maxLevel;\n const out: { block: Paragraph; level: number }[] = [];\n for (const p of bodyParagraphs(doc)) {\n const level = tocHeadingLevel(p, doc, sw);\n if (level === null || level < 1 || level > cap) continue;\n if (sw?.outlineRange && (level < sw.outlineRange.from || level > sw.outlineRange.to)) continue;\n out.push({ block: p, level });\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Imported-TOC unification: an imported TOC field flattens to plain paragraphs\n// (label + \"#anchor\" hyperlink + cached \"\\t<number>\" text). To round-trip it as a\n// LIVE field on export, mark those paragraphs as tocEntry and STRIP the cached\n// number text \u2014 so the number becomes layout-driven (paint decoration), exactly\n// like an editor-created TOC, and export wraps it in a real TOC/PAGEREF field.\n\n/** Mark a document's imported TOC entries as tocEntry and strip their cached page\n * numbers. Gated by the caller (only when a TOC field exists). Returns the count.\n * An entry is a body paragraph with a \"#anchor\" link resolving to a bookmarked\n * block plus a trailing \"\\t<number>\"; level is inferred from its left indent. */\nexport function markImportedTocEntries(doc: Document, indentStepPx = 20): number {\n if (!doc.bookmarks) return 0;\n const contentWidthPx = doc.section.pageWidthPx - doc.section.marginPx.left - doc.section.marginPx.right;\n let count = 0;\n for (const b of doc.blocks) {\n if (b.kind !== \"paragraph\" || b.style.tocEntry) continue;\n const anchor = b.runs.find((r) => r.style.link?.startsWith(\"#\"))?.style.link?.slice(1);\n if (!anchor) continue;\n const targetId = doc.bookmarks[anchor]?.start.blockId;\n if (!targetId) continue;\n const full = textOfRuns(b.runs);\n const m = full.match(/\\t\\d+\\s*$/); // trailing tab + page number\n if (!m) continue;\n b.runs = sliceRuns(b.runs, 0, full.length - m[0].length); // drop \"\\t<number>\"\n if (b.runs.length === 0) b.runs = [{ text: \"\", style: { ...TOC_BASE_CHAR } }];\n const level = Math.min(9, Math.max(1, Math.round((b.style.indentLeftPx || 0) / indentStepPx) + 1));\n b.style.tocEntry = { targetId, level };\n // Right-aligned dot leader so the painted/exported number aligns (if none set).\n if (!b.style.tabStops?.length && contentWidthPx > 0) {\n b.style.tabStops = [{ posPx: contentWidthPx, align: \"right\", leader: \"dot\" }];\n }\n b.revision++;\n count++;\n }\n return count;\n}\n\n// ---------------------------------------------------------------------------\n// Generation options \u2014 everything that used to be hardcoded in\n// buildTocParagraphs, exposed so the editor AND the backend route can pass it.\n\nexport interface TocLevelStyle {\n char?: Partial<CharStyle>;\n para?: Partial<ParaStyle>;\n}\n\nconst TOC_BASE_CHAR: CharStyle = {\n fontFamily: \"Georgia, serif\", fontSizePx: 13, bold: false, italic: false,\n underline: false, strikethrough: false, color: \"#202124\",\n};\n\n/** Resolve the char + paragraph style for a TOC entry at `level`, merging the\n * caller's `TocOptions` over the built-in defaults. Shared by the editor's\n * buildTocParagraphs and the headless generator so they look identical. The para\n * carries the right-aligned dot-leader tab stop (callers add `tocEntry`). */\nexport function tocEntryStyle(\n opts: TocOptions,\n level: number,\n contentWidthPx: number,\n): { char: CharStyle; para: ParaStyle } {\n const indentStep = opts.indentStepPx ?? 20;\n const leader = opts.leader ?? \"dot\";\n const base = { ...TOC_BASE_CHAR, ...opts.baseChar };\n const char: CharStyle = { ...base, fontSizePx: level === 1 ? 14 : 13, bold: level === 1, ...opts.levels?.[level]?.char };\n const para: ParaStyle = {\n align: \"left\", lineHeight: 1.5, spaceBeforePx: 0, spaceAfterPx: 2,\n indentFirstLinePx: 0, indentLeftPx: (level - 1) * indentStep, ...opts.levels?.[level]?.para,\n };\n if (leader !== \"none\" && contentWidthPx > 0) para.tabStops = [{ posPx: contentWidthPx, align: \"right\", leader }];\n return { char, para };\n}\n\n/** The TOC title paragraph's resolved styles, or null when titles are disabled. */\nexport function tocTitleStyle(opts: TocOptions): { text: string; char: CharStyle; para: ParaStyle } | null {\n if (opts.title === null) return null;\n const t = opts.title ?? {};\n const base = { ...TOC_BASE_CHAR, ...opts.baseChar };\n return {\n text: t.text ?? \"Table of Contents\",\n char: { ...base, fontSizePx: 20, bold: true, ...t.char },\n para: {\n align: \"left\", lineHeight: 1.4, spaceBeforePx: 8, spaceAfterPx: 12,\n indentFirstLinePx: 0, indentLeftPx: 0, namedStyle: t.namedStyle ?? \"tocTitle\", ...t.para,\n },\n };\n}\n\nexport interface TocOptions {\n /** TOC title paragraph; null/omit to skip a title. */\n title?: { text?: string; char?: Partial<CharStyle>; para?: Partial<ParaStyle>; namedStyle?: string } | null;\n /** Base char style applied to every entry before per-level overrides. */\n baseChar?: Partial<CharStyle>;\n /** Per-level (1-based) style overrides. */\n levels?: Record<number, TocLevelStyle>;\n /** Deepest heading level to include (default 3). */\n maxLevel?: number;\n /** Per-level left indent step in px (default 20). */\n indentStepPx?: number;\n /** Dot-leader between label and page number (default \"dot\"). */\n leader?: \"dot\" | \"dash\" | \"underscore\" | \"none\";\n /** Show page numbers (default true). */\n includePageNumbers?: boolean;\n /** Emit entries as hyperlinks (default true). */\n hyperlink?: boolean;\n}\n", "// Globally-unique id minting for blocks, cells, and other model nodes.\n//\n// In single-user mode any unique string works. For multi-user collaboration two\n// clients must NEVER mint the same id (a collision would make one client's\n// paragraph silently alias another's), so ids are namespaced by a per-session\n// `siteId`:\n//\n// <siteId>-<counter base36> e.g. \"a3f9c1-0\", \"a3f9c1-1\", \u2026\n//\n// The counter is monotonic within a session; the siteId makes the whole space\n// disjoint from every other session. Replaying an op stream is deterministic\n// because generated ids are captured IN the op (splitParagraph.newBlockId,\n// insertBlock.block.id), so the consumer never re-mints them.\n//\n// Randomness lives at the edges (the frontend mints a siteId via crypto), not\n// here \u2014 this module stays pure and deterministic so it is trivially testable.\n\nexport interface IdGenerator {\n readonly siteId: string;\n /** Next unique id in this site's space. */\n next(): string;\n /** Current counter value (for persisting/resuming a session's id space). */\n count(): number;\n}\n\nexport function createIdGenerator(siteId: string, start = 0): IdGenerator {\n let n = start;\n return {\n siteId,\n next: () => `${siteId}-${(n++).toString(36)}`,\n count: () => n,\n };\n}\n\n// A process-wide default generator. The frontend reconfigures it at startup with\n// a real siteId (see configureIds); tests and any unconfigured context get the\n// deterministic \"local\" space.\nlet active: IdGenerator = createIdGenerator(\"local\");\n\n/** Point the default generator at a session's siteId. Call once at startup,\n * before any ids are minted. `start` resumes a prior session's counter. */\nexport function configureIds(siteId: string, start = 0): void {\n active = createIdGenerator(siteId, start);\n}\n\n/** Mint a fresh unique id from the active generator. */\nexport function freshId(): string {\n return active.next();\n}\n\n/** The active session's siteId (tags Changes so the server can order them). */\nexport function currentSiteId(): string {\n return active.siteId;\n}\n", "// Generic OOXML field-instruction parsing. Pure and layout-free, so import,\n// export, and the editor command layer all share it.\n//\n// A \"field\" in OOXML is a region delimited by w:fldChar begin/separate/end runs\n// with a w:instrText instruction (e.g. ` TOC \\o \"1-3\" \\h `, ` PAGEREF _Toc1 \\h `,\n// ` MYCHART \"sales-2026\" `). Word ships a fixed set of BUILT-IN field types it\n// knows how to render; everything else is a developer-defined CUSTOM field whose\n// result the host computes (see Document.fields + the editor's resolveField hook).\n// This module only classifies an instruction and splits out its name + arguments;\n// TOC has its own richer parser in ./toc.\n\n/** A field instruction split into its keyword and arguments. */\nexport interface ParsedFieldInstruction {\n /** Field keyword, uppercased (e.g. \"TOC\", \"MYCHART\"). \"\" when the instruction\n * is blank. */\n name: string;\n /** Whitespace/quote-delimited tokens after the keyword (surrounding double\n * quotes stripped). Switches like `\\o` appear here verbatim. */\n args: string[];\n /** The instruction exactly as given (untrimmed). */\n raw: string;\n}\n\n/** Field keywords Word renders itself \u2014 NOT treated as host-resolvable custom\n * fields. TOC is built-in here too (the editor handles it on its own tocEntry\n * path), so it is never captured as a generic custom field. */\nconst BUILTIN_FIELDS = new Set([\n \"TOC\", \"PAGE\", \"NUMPAGES\", \"PAGEREF\", \"HYPERLINK\", \"REF\", \"SEQ\", \"STYLEREF\",\n \"DATE\", \"TIME\", \"CREATEDATE\", \"SAVEDATE\", \"PRINTDATE\", \"EDITTIME\", \"FILENAME\",\n \"FILESIZE\", \"AUTHOR\", \"LASTSAVEDBY\", \"TITLE\", \"SUBJECT\", \"KEYWORDS\", \"COMMENTS\",\n \"NUMWORDS\", \"NUMCHARS\", \"TOA\", \"INDEX\", \"XE\", \"TC\", \"TA\", \"RD\",\n \"FORMTEXT\", \"FORMCHECKBOX\", \"FORMDROPDOWN\", \"SYMBOL\", \"QUOTE\", \"IF\",\n \"DOCPROPERTY\", \"DOCVARIABLE\", \"MERGEFIELD\", \"ADDRESSBLOCK\", \"GREETINGLINE\",\n \"FILLIN\", \"ASK\", \"SET\", \"GOTOBUTTON\", \"MACROBUTTON\", \"EQ\", \"LISTNUM\",\n \"BIBLIOGRAPHY\", \"CITATION\", \"SECTION\", \"SECTIONPAGES\", \"AUTONUM\", \"AUTONUMLGL\",\n \"AUTONUMOUT\", \"INCLUDETEXT\", \"INCLUDEPICTURE\", \"LINK\", \"NOTEREF\",\n]);\n\n/** Is `name` one of Word's built-in field types (so NOT a custom field)? */\nexport function isBuiltinField(name: string): boolean {\n return BUILTIN_FIELDS.has(name.trim().toUpperCase());\n}\n\n/** Tokenize a field instruction, honoring double-quoted arguments. */\nfunction tokenizeField(instr: string): string[] {\n const out: string[] = [];\n const re = /\"([^\"]*)\"|(\\S+)/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(instr)) !== null) out.push(m[1] !== undefined ? m[1] : m[2]!);\n return out;\n}\n\n/** Parse a field instruction into its keyword + arguments. */\nexport function parseFieldInstruction(instr: string): ParsedFieldInstruction {\n const tokens = tokenizeField(instr);\n return { name: tokens.length > 0 ? tokens[0]!.toUpperCase() : \"\", args: tokens.slice(1), raw: instr };\n}\n\n/** Is this instruction a host-resolvable custom field (has a keyword and isn't a\n * Word built-in)? Drives import capture + the editor's \"Update Field\" menu. */\nexport function isCustomFieldInstruction(instr: string): boolean {\n const name = parseFieldInstruction(instr).name;\n return name.length > 0 && !isBuiltinField(name);\n}\n"],
5
- "mappings": "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",
6
- "names": []
7
- }