@stll/folio-core 0.33.2 → 0.35.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.
Files changed (49) hide show
  1. package/dist/ai-edits/apply.js +137 -23
  2. package/dist/ai-edits/snapshot.d.ts +3 -1
  3. package/dist/ai-edits/snapshot.js +44 -4
  4. package/dist/ai-edits/types.d.ts +6 -6
  5. package/dist/compare/compare.js +15 -3
  6. package/dist/compare/formatting.d.ts +1 -1
  7. package/dist/compare/formatting.js +38 -9
  8. package/dist/compare/verification.js +18 -1
  9. package/dist/controller/headerFooterEditorManager.js +11 -9
  10. package/dist/controller/layoutPipeline.js +24 -3
  11. package/dist/display-list/build/watermarkPrimitives.js +15 -3
  12. package/dist/document-operations.js +30 -3
  13. package/dist/docx/headerFooterParser.js +8 -14
  14. package/dist/docx/paragraphParser.js +45 -0
  15. package/dist/docx/serializer/headerFooterSerializer.js +21 -2
  16. package/dist/docx/serializer/paragraphSerializer.d.ts +1 -1
  17. package/dist/docx/serializer/paragraphSerializer.js +19 -9
  18. package/dist/docx/settingsParser.js +3 -0
  19. package/dist/docx/watermarkParser.d.ts +2 -4
  20. package/dist/docx/watermarkParser.js +4 -6
  21. package/dist/headless-layout.js +14 -2
  22. package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -2
  23. package/dist/layout-bridge/convert/footnoteLayout.js +23 -10
  24. package/dist/layout-bridge/convert/headerFooterLayout.js +13 -2
  25. package/dist/layout-bridge/convert/toFlowBlocks.js +96 -13
  26. package/dist/layout-engine/index.js +11 -4
  27. package/dist/layout-engine/justifiedLineFit.d.ts +4 -4
  28. package/dist/layout-engine/justifiedLineFit.js +4 -4
  29. package/dist/layout-engine/measure/lineBreakProvider.js +1 -0
  30. package/dist/layout-engine/measure/measureBlocks.js +1 -1
  31. package/dist/layout-engine/measure/measureParagraph.js +29 -29
  32. package/dist/layout-painter/renderPage.js +6 -1
  33. package/dist/layout-painter/renderParagraph.js +18 -8
  34. package/dist/layout-painter/renderWatermark.js +11 -4
  35. package/dist/prosemirror/attrs/index.js +11 -0
  36. package/dist/prosemirror/commands/comments.js +22 -2
  37. package/dist/prosemirror/conversion/fromProseDoc.js +32 -5
  38. package/dist/prosemirror/conversion/toProseDoc.js +49 -11
  39. package/dist/prosemirror/extensions/core/DocExtension.js +7 -1
  40. package/dist/prosemirror/extensions/core/ParagraphExtension.js +1 -0
  41. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +1 -0
  42. package/dist/prosemirror/plugins/templateDirectives.d.ts +14 -7
  43. package/dist/prosemirror/plugins/templateDirectives.js +26 -19
  44. package/dist/prosemirror/plugins/templateSlashMenu.js +2 -2
  45. package/dist/prosemirror/schema/marks.d.ts +2 -8
  46. package/dist/utils/fontResolver.js +51 -0
  47. package/dist/utils/formatToStyle.js +41 -1
  48. package/dist/watermark/index.js +7 -0
  49. package/package.json +3 -3
@@ -689,9 +689,9 @@ function createTrackedRunWrapper(type, info, child) {
689
689
  content
690
690
  };
691
691
  }
692
- function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, textBoxAnchorMarkers, skipLeadingRenderedPageBreak = false) {
692
+ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, textBoxAnchorMarkers, skipLeadingRenderedPageBreak = false, inheritedFormattingOverride) {
693
693
  const content = [];
694
- const inheritedFormatting = paragraph.type.name === "paragraph" ? expectParagraphAttrs(paragraph).defaultTextFormatting ?? void 0 : void 0;
694
+ const inheritedFormatting = inheritedFormattingOverride ?? (paragraph.type.name === "paragraph" ? expectParagraphAttrs(paragraph).defaultTextFormatting ?? void 0 : void 0);
695
695
  const sortedEmptyHyperlinks = (emptyHyperlinks ?? []).map((attrs, order) => ({
696
696
  attrs,
697
697
  order
@@ -966,7 +966,7 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
966
966
  }));
967
967
  } else if (node.type.name === "sdt") {
968
968
  flushCurrentInline();
969
- content.push(createInlineSdtFromNode(node, textBoxAnchorMarkers));
969
+ content.push(createInlineSdtFromNode(node, textBoxAnchorMarkers, inheritedFormatting));
970
970
  } else if (node.type.name === "math") {
971
971
  flushCurrentInline();
972
972
  content.push(createMathFromNode(node));
@@ -1369,11 +1369,11 @@ function createMathFromNode(node) {
1369
1369
  /**
1370
1370
  * Create an InlineSdt from a PM sdt node
1371
1371
  */
1372
- function createInlineSdtFromNode(node, textBoxAnchorMarkers) {
1372
+ function createInlineSdtFromNode(node, textBoxAnchorMarkers, inheritedFormatting) {
1373
1373
  return {
1374
1374
  type: "inlineSdt",
1375
1375
  properties: sdtPropertiesFromAttrs(expectSdtAttrs(node)),
1376
- content: extractParagraphContent(node, void 0, void 0, textBoxAnchorMarkers).filter((c) => c.type === "run" || c.type === "hyperlink" || c.type === "simpleField" || c.type === "complexField" || c.type === "inlineSdt" || c.type === "insertion" || c.type === "deletion" || c.type === "moveFrom" || c.type === "moveTo" || c.type === "mathEquation")
1376
+ content: extractParagraphContent(node, void 0, void 0, textBoxAnchorMarkers, false, inheritedFormatting).filter((c) => c.type === "run" || c.type === "hyperlink" || c.type === "simpleField" || c.type === "complexField" || c.type === "inlineSdt" || c.type === "insertion" || c.type === "deletion" || c.type === "moveFrom" || c.type === "moveTo" || c.type === "mathEquation")
1377
1377
  };
1378
1378
  }
1379
1379
  /**
@@ -1524,6 +1524,7 @@ function createShapeRun(node) {
1524
1524
  function marksToTextFormatting(marks, options) {
1525
1525
  const formatting = {};
1526
1526
  let directOverrideFormatting;
1527
+ let directFontProperties;
1527
1528
  let characterStyleRPr;
1528
1529
  let runFormattingOverrideMark;
1529
1530
  for (const mark of marks) switch (mark.type.name) {
@@ -1651,9 +1652,14 @@ function marksToTextFormatting(marks, options) {
1651
1652
  }
1652
1653
  if (runFormattingOverrideMark) {
1653
1654
  const overrideAttrs = expectRunFormattingOverrideMarkAttrs(runFormattingOverrideMark);
1655
+ directFontProperties = overrideAttrs.directFontProperties;
1654
1656
  applyRunFormattingOverrideAttrs(formatting, overrideAttrs);
1655
1657
  directOverrideFormatting = {};
1656
1658
  applyRunFormattingOverrideAttrs(directOverrideFormatting, overrideAttrs);
1659
+ for (const property of directFontProperties ?? []) {
1660
+ const value = formatting[property];
1661
+ if (value !== void 0) Reflect.set(directOverrideFormatting, property, value);
1662
+ }
1657
1663
  }
1658
1664
  if (characterStyleRPr) return subtractCharacterStyleFormatting({
1659
1665
  directOverrideFormatting,
@@ -1661,8 +1667,29 @@ function marksToTextFormatting(marks, options) {
1661
1667
  inheritedFormatting: options?.inheritedFormatting,
1662
1668
  styleRPr: characterStyleRPr
1663
1669
  });
1670
+ for (const property of [
1671
+ "fontFamily",
1672
+ "fontSize",
1673
+ "color"
1674
+ ]) {
1675
+ if (directFontProperties?.includes(property)) continue;
1676
+ const value = formatting[property];
1677
+ const inheritedValue = options?.inheritedFormatting?.[property];
1678
+ const matchesInherited = property === "fontFamily" ? sameFontFamily(formatting.fontFamily, options?.inheritedFormatting?.fontFamily) : JSON.stringify(value) === JSON.stringify(inheritedValue);
1679
+ if (inheritedValue !== void 0 && matchesInherited) Reflect.deleteProperty(formatting, property);
1680
+ }
1664
1681
  return formatting;
1665
1682
  }
1683
+ const sameFontFamily = (left, right) => [
1684
+ "ascii",
1685
+ "hAnsi",
1686
+ "eastAsia",
1687
+ "hint",
1688
+ "asciiTheme",
1689
+ "hAnsiTheme",
1690
+ "eastAsiaTheme",
1691
+ "csTheme"
1692
+ ].every((property) => left?.[property] === right?.[property]) && (left?.cs ?? left?.ascii) === (right?.cs ?? right?.ascii);
1666
1693
  /**
1667
1694
  * Negatable boolean run-property keys whose serializer emits an explicit
1668
1695
  * `w:val="0"` override when the value is `false` (see `serializeTextFormatting`
@@ -18,6 +18,7 @@ import { shadingToRunShadingAttrs } from "./runShadingMark.js";
18
18
  import { sdtAttrsFromProperties } from "./sdtAttrs.js";
19
19
  import { panic } from "better-result";
20
20
  //#region src/prosemirror/conversion/toProseDoc.ts
21
+ const DETACHED_WATERMARK_HOST = Symbol.for("stll.detachedWatermarkHost");
21
22
  /**
22
23
  * Build a `nextTextBoxGroupId()` generator salted with a random per-load
23
24
  * nonce, so minted text-box anchor ids (`<salt>:<group>:<index>`) are unique
@@ -139,7 +140,12 @@ function toProseDoc(document, options) {
139
140
  };
140
141
  nodes.push(...convertBodyBlocks(paragraphs));
141
142
  if (nodes.length === 0) nodes.push(schema.node("paragraph", {}, []));
142
- const pmDoc = stampNumberedRefFieldBaselines(schema.node("doc", null, nodes));
143
+ const finalSectionStart = document.package.document.sections?.at(-1)?.properties.sectionStart ?? null;
144
+ const adjustLineHeightInTable = document.package.settings?.adjustLineHeightInTable === true;
145
+ const pmDoc = stampNumberedRefFieldBaselines(schema.node("doc", {
146
+ _finalSectionStart: finalSectionStart,
147
+ _adjustLineHeightInTable: adjustLineHeightInTable
148
+ }, nodes));
143
149
  assertValidProseMirrorDocument(pmDoc, "Document conversion produced an invalid ProseMirror document");
144
150
  return pmDoc;
145
151
  }
@@ -210,6 +216,7 @@ function convertParagraph(paragraph, styleResolver, nextHyperlinkInstanceIndex,
210
216
  const paragraphRunFormatting = resolveRunFormattingWithoutDefaults(paragraph.formatting?.runProperties, styleResolver);
211
217
  let inheritableParagraphRunFormatting;
212
218
  if (paragraphRunFormatting && !isTocParagraph) inheritableParagraphRunFormatting = stripParagraphMarkFormattingForBodyRuns(paragraphRunFormatting);
219
+ const ordinaryStyleFormatting = paragraph.formatting?.styleId === void 0 ? mergeTextFormatting(styleRunFormatting, extraRunFormatting) : mergeTextFormatting(extraRunFormatting, styleRunFormatting);
213
220
  const orderedToggleFormatting = cascadeStyleTextFormatting([
214
221
  {
215
222
  formatting: styleResolver?.getDocDefaults()?.rPr,
@@ -223,7 +230,7 @@ function convertParagraph(paragraph, styleResolver, nextHyperlinkInstanceIndex,
223
230
  formatting: paragraphStyleRunFormatting,
224
231
  type: "style"
225
232
  }
226
- ], { ordinaryFormatting: mergeTextFormatting(styleRunFormatting, extraRunFormatting) });
233
+ ], { ordinaryFormatting: ordinaryStyleFormatting });
227
234
  let baseRunFormatting = orderedToggleFormatting.formatting;
228
235
  if (paragraphStyleFontFamily) baseRunFormatting = mergeTextFormatting(baseRunFormatting, { fontFamily: paragraphStyleFontFamily });
229
236
  const paragraphMarkPrecedesStyle = paragraph.formatting?.styleId !== void 0;
@@ -1000,10 +1007,10 @@ function convertTableRow(row, styleResolver, context, isHeaderRow, columnWidths,
1000
1007
  let cellConditionalStyle = conditionalStyles?.wholeTable;
1001
1008
  cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, effectiveRowBandStyle);
1002
1009
  cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, vertBandStyle);
1003
- if (cellIsFirstRow && (tableLook?.firstRow || rowCnf?.firstRow || cellCnf?.firstRow)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.firstRow);
1004
- if (cellIsLastRow && (tableLook?.lastRow || rowCnf?.lastRow || cellCnf?.lastRow)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.lastRow);
1005
1010
  if (cellIsFirstCol && (tableLook?.firstColumn || rowCnf?.firstColumn || cellCnf?.firstColumn)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.firstCol);
1006
1011
  if (cellIsLastCol && (tableLook?.lastColumn || rowCnf?.lastColumn || cellCnf?.lastColumn)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.lastCol);
1012
+ if (cellIsFirstRow && (tableLook?.firstRow || rowCnf?.firstRow || cellCnf?.firstRow)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.firstRow);
1013
+ if (cellIsLastRow && (tableLook?.lastRow || rowCnf?.lastRow || cellCnf?.lastRow)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.lastRow);
1007
1014
  if (cellIsFirstRow && cellIsFirstCol && (tableLook?.firstRow || rowCnf?.firstRow || cellCnf?.firstRow) && (tableLook?.firstColumn || rowCnf?.firstColumn || cellCnf?.firstColumn)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.nwCell);
1008
1015
  if (cellIsFirstRow && cellIsLastCol && (tableLook?.firstRow || rowCnf?.firstRow || cellCnf?.firstRow) && (tableLook?.lastColumn || rowCnf?.lastColumn || cellCnf?.lastColumn)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.neCell);
1009
1016
  if (cellIsLastRow && cellIsFirstCol && (tableLook?.lastRow || rowCnf?.lastRow || cellCnf?.lastRow) && (tableLook?.firstColumn || rowCnf?.firstColumn || cellCnf?.firstColumn)) cellConditionalStyle = mergeConditionalStyles(cellConditionalStyle, conditionalStyles?.swCell);
@@ -1265,6 +1272,7 @@ function buildRunMarks(runFormatting, inherited, styleResolver) {
1265
1272
  hasCharacterStyle: styleId !== void 0,
1266
1273
  paragraphMarkOverrides: inherited.paragraphMarkOverrides
1267
1274
  }) });
1275
+ addDirectFontProvenance(marks, runFormatting);
1268
1276
  if (styleId) {
1269
1277
  const styleRPr = characterStyleFormatting ? marksToTextFormatting(textFormattingToMarks(characterStyleFormatting)) : void 0;
1270
1278
  marks.push(schema.mark("characterStyle", {
@@ -1277,6 +1285,24 @@ function buildRunMarks(runFormatting, inherited, styleResolver) {
1277
1285
  mergedFormatting
1278
1286
  };
1279
1287
  }
1288
+ const addDirectFontProvenance = (marks, directFormatting) => {
1289
+ const directFontProperties = [];
1290
+ if (directFormatting?.fontFamily !== void 0) directFontProperties.push("fontFamily");
1291
+ if (directFormatting?.fontSize !== void 0) directFontProperties.push("fontSize");
1292
+ if (directFormatting?.color !== void 0) directFontProperties.push("color");
1293
+ if (!directFormatting || !Object.keys(directFormatting).some((property) => property !== "styleId")) return;
1294
+ const index = marks.findIndex(({ type }) => type.name === "runFormattingOverride");
1295
+ const existing = index >= 0 ? marks.at(index) : void 0;
1296
+ const override = schema.mark("runFormattingOverride", {
1297
+ ...existing?.attrs,
1298
+ ...directFontProperties.length > 0 && { directFontProperties }
1299
+ });
1300
+ if (index >= 0) {
1301
+ marks[index] = override;
1302
+ return;
1303
+ }
1304
+ marks.push(override);
1305
+ };
1280
1306
  const ORDINARY_STYLE_TOGGLE_KEYS = [
1281
1307
  "bold",
1282
1308
  "italic",
@@ -1658,12 +1684,12 @@ function convertShape(shape) {
1658
1684
  position
1659
1685
  });
1660
1686
  }
1661
- function convertParagraphWithTextBoxes(block, styleResolver, { textBoxGroupId, context, extraRunFormatting, tableParagraphOverlay }) {
1687
+ function convertParagraphWithTextBoxes(block, styleResolver, { textBoxGroupId, context, extraRunFormatting, preserveEmptyWrapper, tableParagraphOverlay }) {
1662
1688
  const { textBoxes, textBoxAnchors } = extractTextBoxesFromParagraph(block, textBoxGroupId);
1663
1689
  const pmParagraph = convertParagraph(block, styleResolver, context.nextHyperlinkInstanceIndex, context.pairedBookmarkIds, void 0, extraRunFormatting, tableParagraphOverlay, textBoxAnchors);
1664
1690
  const nodes = [];
1665
1691
  const isEmptyAfterExtraction = textBoxes.length > 0 && !hasContentBesidesTextBoxAnchors(pmParagraph);
1666
- const keepWrapperParagraph = isEmptyAfterExtraction && hasParagraphBoundaryPayload(block, pmParagraph);
1692
+ const keepWrapperParagraph = isEmptyAfterExtraction && (preserveEmptyWrapper === true || hasParagraphBoundaryPayload(block, pmParagraph));
1667
1693
  if (!isEmptyAfterExtraction || keepWrapperParagraph) nodes.push(pmParagraph);
1668
1694
  for (const { textBox, anchorId, trackedChange, inlineSdts } of textBoxes) nodes.push(convertTextBox(textBox, styleResolver, {
1669
1695
  placement: isEmptyAfterExtraction && !keepWrapperParagraph ? "standalone" : "inlineWithPrevious",
@@ -1929,11 +1955,23 @@ function headerFooterToProseDoc(content, options) {
1929
1955
  };
1930
1956
  const convertBlocks = (blocks) => {
1931
1957
  const out = [];
1932
- for (const block of blocks) if (block.type === "paragraph") out.push(...convertParagraphWithTextBoxes(block, styleResolver, {
1933
- textBoxGroupId: nextTextBoxGroupId(),
1934
- context: conversionContext
1935
- }));
1936
- else if (block.type === "table") out.push(convertTable(block, styleResolver, conversionContext));
1958
+ for (const block of blocks) if (block.type === "paragraph") {
1959
+ const isDetachedWatermarkHost = Reflect.get(block, DETACHED_WATERMARK_HOST) === true;
1960
+ const paragraphNodes = convertParagraphWithTextBoxes(block, styleResolver, {
1961
+ textBoxGroupId: nextTextBoxGroupId(),
1962
+ context: conversionContext,
1963
+ preserveEmptyWrapper: isDetachedWatermarkHost
1964
+ });
1965
+ if (isDetachedWatermarkHost) {
1966
+ const paragraphNodeIndex = paragraphNodes.findIndex(({ type }) => type.name === "paragraph");
1967
+ const paragraphNode = paragraphNodes[paragraphNodeIndex];
1968
+ if (paragraphNode) paragraphNodes[paragraphNodeIndex] = paragraphNode.type.create({
1969
+ ...paragraphNode.attrs,
1970
+ _detachedWatermarkHost: true
1971
+ }, paragraphNode.content, paragraphNode.marks);
1972
+ }
1973
+ out.push(...paragraphNodes);
1974
+ } else if (block.type === "table") out.push(convertTable(block, styleResolver, conversionContext));
1937
1975
  else out.push(convertBlockSdt(block, convertBlocks));
1938
1976
  return out;
1939
1977
  };
@@ -6,7 +6,13 @@ import { createNodeExtension } from "../create.js";
6
6
  const DocExtension = createNodeExtension({
7
7
  name: "doc",
8
8
  schemaNodeName: "doc",
9
- nodeSpec: { content: "(paragraph | horizontalRule | pageBreak | table | textBox | blockSdt)+" }
9
+ nodeSpec: {
10
+ attrs: {
11
+ _finalSectionStart: { default: null },
12
+ _adjustLineHeightInTable: { default: false }
13
+ },
14
+ content: "(paragraph | horizontalRule | pageBreak | table | textBox | blockSdt)+"
15
+ }
10
16
  });
11
17
  //#endregion
12
18
  export { DocExtension };
@@ -223,6 +223,7 @@ const paragraphNodeSpec = {
223
223
  pageBreakBefore: { default: null },
224
224
  renderedPageBreakBefore: { default: null },
225
225
  _pageBreakCarrier: { default: null },
226
+ _detachedWatermarkHost: { default: null },
226
227
  _trailingPageBreak: { default: null },
227
228
  keepNext: { default: null },
228
229
  keepLines: { default: null },
@@ -50,6 +50,7 @@ const RunFormattingOverrideExtension = createMarkExtension({
50
50
  schemaMarkName: "runFormattingOverride",
51
51
  markSpec: {
52
52
  attrs: {
53
+ directFontProperties: { default: null },
53
54
  bold: { default: null },
54
55
  italic: { default: null },
55
56
  underline: { default: null },
@@ -9,10 +9,16 @@ type DirectiveRange = {
9
9
  /** Exclusive PM doc position of the marker end. */
10
10
  to: number;
11
11
  kind: DirectiveKind;
12
- /** Field path, clause name, or condition/loop expression. */
12
+ /**
13
+ * Field path, clause name, key, condition, or — for a `for` marker — the
14
+ * array path the loop iterates (`{% for row in items %}` ⇒ `items`).
15
+ */
13
16
  expr: string;
14
17
  /** Clause-slot version selector, e.g. "v3" or "latest". */
15
18
  clauseVersion?: string;
19
+ /** Loop alias of a `for` marker (`{% for row in items %}` ⇒ `row`); unset
20
+ * for every other kind. */
21
+ alias?: string;
16
22
  /** True for block directives that occupy their own paragraph. */
17
23
  block: boolean;
18
24
  };
@@ -20,15 +26,16 @@ type DirectiveRange = {
20
26
  * Nesting depth (0-based) of every block-directive opener, derived purely from
21
27
  * the scanned ranges by containment: walk the block openers/closers in document
22
28
  * order with a kind-aware stack, and record each opener's depth as the stack size
23
- * before it is pushed. Only `block:true` if/each pairs participate (inline markers
29
+ * before it is pushed. Only `block:true` if/for pairs participate (inline markers
24
30
  * resolve within a paragraph and get no rail).
25
31
  *
26
32
  * Matching is kind-aware so a mid-edit / unbalanced template stays sane: a closer
27
- * pops the nearest opener of the *same family* ({{/if}} ⇒ {{#if}}, {{/each}} ⇒
28
- * {{#each}}), dropping any still-open openers nested above it; a closer with no
29
- * matching opener is ignored (never decrements a foreign block's depth). A blind
30
- * open/close counter would mis-count here: e.g. a stray {{/each}} between {{#if}}
31
- * and a nested {{#each}} would wrongly pull the inner {{#each}} back to depth 0.
33
+ * pops the nearest opener of the *same family* (`{% endif %}``{% if %}`,
34
+ * `{% endfor %}` ⇒ `{% for %}`), dropping any still-open openers nested above it;
35
+ * a closer with no matching opener is ignored (never decrements a foreign block's
36
+ * depth). A blind open/close counter would mis-count here: e.g. a stray
37
+ * `{% endfor %}` between `{% if %}` and a nested `{% for %}` would wrongly pull
38
+ * the inner `{% for %}` back to depth 0.
32
39
  *
33
40
  * Keyed by the opener's `from` PM position, which is unique per marker, so the
34
41
  * overlay can look a band's depth up from its opener range. This is a pure
@@ -3,41 +3,42 @@ import { collectBlockChunks, joinChunks, offsetToDocPos } from "./pmTextScan.js"
3
3
  import { PluginKey } from "prosemirror-state";
4
4
  import { assertNever, isBlockDirectiveKind, scanMarkers } from "@stll/template-conditions";
5
5
  //#region src/prosemirror/plugins/templateDirectives.ts
6
- /** The display expression for a marker (field path, clause name, key, condition). */
6
+ /** The display expression for a marker (field path, clause name, key,
7
+ * condition, loop array path, loop property). */
7
8
  const directiveExpr = (meta) => {
8
9
  switch (meta.kind) {
9
10
  case "placeholder": return meta.expr;
10
11
  case "clause": return meta.name;
11
12
  case "num":
12
13
  case "ref": return meta.key;
14
+ case "loop": return meta.property;
13
15
  case "if":
14
- case "elseif":
15
- case "each": return meta.expr;
16
- case "index":
17
- case "count":
16
+ case "elif": return meta.expr;
17
+ case "for": return meta.path;
18
18
  case "else":
19
19
  case "endif":
20
- case "endeach": return "";
20
+ case "endfor": return "";
21
21
  default: return assertNever(meta);
22
22
  }
23
23
  };
24
- /** Block-directive openers ({{#if}}, {{#each}}) that start a gutter-rail band. */
25
- const BLOCK_OPENER_KINDS = /* @__PURE__ */ new Set(["if", "each"]);
26
- /** Block-directive closers ({{/if}}, {{/each}}) that end a gutter-rail band. */
27
- const BLOCK_CLOSER_KINDS = /* @__PURE__ */ new Set(["endif", "endeach"]);
24
+ /** Block-directive openers (`{% if %}`, `{% for %}`) that start a gutter-rail band. */
25
+ const BLOCK_OPENER_KINDS = /* @__PURE__ */ new Set(["if", "for"]);
26
+ /** Block-directive closers (`{% endif %}`, `{% endfor %}`) that end a gutter-rail band. */
27
+ const BLOCK_CLOSER_KINDS = /* @__PURE__ */ new Set(["endif", "endfor"]);
28
28
  /**
29
29
  * Nesting depth (0-based) of every block-directive opener, derived purely from
30
30
  * the scanned ranges by containment: walk the block openers/closers in document
31
31
  * order with a kind-aware stack, and record each opener's depth as the stack size
32
- * before it is pushed. Only `block:true` if/each pairs participate (inline markers
32
+ * before it is pushed. Only `block:true` if/for pairs participate (inline markers
33
33
  * resolve within a paragraph and get no rail).
34
34
  *
35
35
  * Matching is kind-aware so a mid-edit / unbalanced template stays sane: a closer
36
- * pops the nearest opener of the *same family* ({{/if}} ⇒ {{#if}}, {{/each}} ⇒
37
- * {{#each}}), dropping any still-open openers nested above it; a closer with no
38
- * matching opener is ignored (never decrements a foreign block's depth). A blind
39
- * open/close counter would mis-count here: e.g. a stray {{/each}} between {{#if}}
40
- * and a nested {{#each}} would wrongly pull the inner {{#each}} back to depth 0.
36
+ * pops the nearest opener of the *same family* (`{% endif %}``{% if %}`,
37
+ * `{% endfor %}` ⇒ `{% for %}`), dropping any still-open openers nested above it;
38
+ * a closer with no matching opener is ignored (never decrements a foreign block's
39
+ * depth). A blind open/close counter would mis-count here: e.g. a stray
40
+ * `{% endfor %}` between `{% if %}` and a nested `{% for %}` would wrongly pull
41
+ * the inner `{% for %}` back to depth 0.
41
42
  *
42
43
  * Keyed by the opener's `from` PM position, which is unique per marker, so the
43
44
  * overlay can look a band's depth up from its opener range. This is a pure
@@ -54,12 +55,14 @@ const computeBlockDepths = (ranges) => {
54
55
  stack.push(range.kind);
55
56
  continue;
56
57
  }
57
- const wantOpener = range.kind === "endif" ? "if" : "each";
58
+ const wantOpener = range.kind === "endif" ? "if" : "for";
58
59
  const matchIdx = stack.lastIndexOf(wantOpener);
59
60
  if (matchIdx !== -1) stack.length = matchIdx;
60
61
  }
61
62
  return depths;
62
63
  };
64
+ /** The loop alias a `for` marker binds, or undefined for every other kind. */
65
+ const directiveAlias = (meta) => meta.kind === "for" ? meta.alias : void 0;
63
66
  const scanDirectives = (doc) => {
64
67
  const ranges = [];
65
68
  for (const chunks of collectBlockChunks(doc)) {
@@ -69,24 +72,28 @@ const scanDirectives = (doc) => {
69
72
  const sole = lineMarkers.length === 1 ? lineMarkers[0] : void 0;
70
73
  if (sole && sole.raw === trimmed && isBlockDirectiveKind(sole.meta.kind)) {
71
74
  const last = chunks.at(-1);
75
+ const alias = directiveAlias(sole.meta);
72
76
  ranges.push({
73
77
  from: chunks[0]?.start ?? 0,
74
78
  to: last ? last.end ?? last.start + last.text.length : 0,
75
79
  kind: sole.meta.kind,
76
80
  expr: directiveExpr(sole.meta),
77
- block: true
81
+ block: true,
82
+ ...alias !== void 0 ? { alias } : {}
78
83
  });
79
84
  continue;
80
85
  }
81
86
  for (const marker of scanMarkers(joined)) {
82
87
  const clauseVersion = marker.meta.kind === "clause" ? marker.meta.version : void 0;
88
+ const alias = directiveAlias(marker.meta);
83
89
  ranges.push({
84
90
  from: offsetToDocPos(chunks, marker.start),
85
91
  to: offsetToDocPos(chunks, marker.end, "end"),
86
92
  kind: marker.meta.kind,
87
93
  expr: directiveExpr(marker.meta),
88
94
  block: false,
89
- ...clauseVersion !== void 0 ? { clauseVersion } : {}
95
+ ...clauseVersion !== void 0 ? { clauseVersion } : {},
96
+ ...alias !== void 0 ? { alias } : {}
90
97
  });
91
98
  }
92
99
  }
@@ -24,8 +24,8 @@ const atTriggerBoundary = (state, pos) => {
24
24
  /** Whether `pos` falls strictly inside an existing template directive. The
25
25
  * slash activations insert markers as raw text rather than going through
26
26
  * `insertInline`'s overlap guard, so opening here would nest markers — e.g. a
27
- * `/` typed after `#if ` inside `{{#if condition}}` could produce
28
- * `{{#if {{field}}}}`, which the scanner/fill grammar cannot interpret.
27
+ * `/` typed after `if ` inside `{% if condition %}` could produce
28
+ * `{% if {{ field }} %}`, which the scanner/fill grammar cannot interpret.
29
29
  * Boundaries are exclusive: a caret right before `{{` or after `}}` is fine. */
30
30
  const insideDirective = (state, pos) => getTemplateDirectives(state).some((range) => pos > range.from && pos < range.to);
31
31
  /** Whether the `/` that opened the trigger is still present at `from`. */
@@ -121,17 +121,11 @@ type RunPropertyChangeMarkAttrs = {
121
121
  provenance: TrackedChangeProvenance;
122
122
  suggestionId?: string;
123
123
  };
124
- type RunFormattingOverrideAttrs = { [K in keyof Pick<document_d_exports.TextFormatting, "bold" | "italic" | "strike" | "allCaps" | "smallCaps" | "hidden" | "emboss" | "imprint" | "shadow" | "outline">]?: boolean; } & {
124
+ type RunFormattingOverrideAttrs = Partial<Record<"bold" | "boldCs" | "cs" | "italic" | "italicCs" | "strike" | "allCaps" | "smallCaps" | "hidden" | "emboss" | "imprint" | "shadow" | "outline", boolean>> & {
125
+ directFontProperties?: readonly ("fontFamily" | "fontSize" | "color")[];
125
126
  doubleStrike?: false;
126
127
  rtl?: false;
127
- /** Independent complex-script weight (`w:bCs`). */
128
- boldCs?: boolean;
129
- /** Force complex-script formatting for the full run (`w:cs`). */
130
- cs?: boolean;
131
- /** Independent complex-script size in half-points (`w:szCs`). */
132
128
  fontSizeCs?: number;
133
- /** Independent complex-script slant (`w:iCs`). */
134
- italicCs?: boolean;
135
129
  underline?: "none";
136
130
  };
137
131
  /**
@@ -191,6 +191,23 @@ const FONT_MAPPINGS = {
191
191
  unitsPerEm: 2048
192
192
  })
193
193
  },
194
+ david: {
195
+ googleFont: "Noto Serif Hebrew",
196
+ category: "serif",
197
+ fallbackStack: [
198
+ "David",
199
+ "Noto Serif Hebrew",
200
+ "Times New Roman",
201
+ "serif"
202
+ ],
203
+ singleLineRatio: singleLineRatioOf({
204
+ source: "hhea",
205
+ hheaAscent: 1505,
206
+ hheaDescent: -510,
207
+ hheaLineGap: 0,
208
+ unitsPerEm: 2048
209
+ })
210
+ },
194
211
  georgia: {
195
212
  googleFont: "Tinos",
196
213
  category: "serif",
@@ -628,6 +645,40 @@ const FONT_MAPPINGS = {
628
645
  note: "Measured at 11pt against a 15.36pt reference-layout line pitch; the face ships with the office suite, not as a readable font file."
629
646
  })
630
647
  },
648
+ frankruehl: {
649
+ googleFont: "Frank Ruhl Libre",
650
+ category: "serif",
651
+ fallbackStack: [
652
+ "FrankRuehl",
653
+ "Frank Ruhl Libre",
654
+ "Times New Roman",
655
+ "serif"
656
+ ],
657
+ singleLineRatio: singleLineRatioOf({
658
+ source: "hhea",
659
+ hheaAscent: 1462,
660
+ hheaDescent: -442,
661
+ hheaLineGap: 0,
662
+ unitsPerEm: 2048
663
+ })
664
+ },
665
+ miriam: {
666
+ googleFont: "Miriam Libre",
667
+ category: "sans-serif",
668
+ fallbackStack: [
669
+ "Miriam",
670
+ "Miriam Libre",
671
+ "Arial",
672
+ "sans-serif"
673
+ ],
674
+ singleLineRatio: singleLineRatioOf({
675
+ source: "hhea",
676
+ hheaAscent: 1546,
677
+ hheaDescent: -512,
678
+ hheaLineGap: 0,
679
+ unitsPerEm: 2048
680
+ })
681
+ },
631
682
  "ms mincho": {
632
683
  googleFont: "Noto Serif JP",
633
684
  category: "serif",
@@ -151,6 +151,33 @@ function borderToStyle(border, side = "", theme) {
151
151
  });
152
152
  return style;
153
153
  }
154
+ const HALF_STEP_SHADING_PATTERNS = /* @__PURE__ */ new Set([
155
+ "pct12",
156
+ "pct37",
157
+ "pct62",
158
+ "pct87"
159
+ ]);
160
+ const AUTO_PATTERN_COLOR = "000000";
161
+ const AUTO_PATTERN_BACKGROUND = "FFFFFF";
162
+ function percentageShadingRatio(pattern) {
163
+ if (!pattern?.startsWith("pct")) return;
164
+ const percentage = Number.parseInt(pattern.slice(3), 10);
165
+ if (!Number.isFinite(percentage)) return;
166
+ return (percentage + (HALF_STEP_SHADING_PATTERNS.has(pattern) ? .5 : 0)) / 100;
167
+ }
168
+ function resolvePatternHex({ color, fallback, theme }) {
169
+ const resolved = resolveShadingColor(color, theme);
170
+ return /^#(?<hex>[0-9A-F]{6})$/iu.exec(resolved)?.groups?.["hex"]?.toUpperCase() ?? fallback;
171
+ }
172
+ function blendShadingColors({ foreground, background, ratio }) {
173
+ const channels = [];
174
+ for (let offset = 0; offset < 6; offset += 2) {
175
+ const foregroundChannel = Number.parseInt(foreground.slice(offset, offset + 2), 16);
176
+ const backgroundChannel = Number.parseInt(background.slice(offset, offset + 2), 16);
177
+ channels.push(Math.round(foregroundChannel * ratio + backgroundChannel * (1 - ratio)).toString(16).padStart(2, "0"));
178
+ }
179
+ return `#${channels.join("").toUpperCase()}`;
180
+ }
154
181
  /**
155
182
  * Convert ShadingProperties to background color
156
183
  *
@@ -161,13 +188,26 @@ function borderToStyle(border, side = "", theme) {
161
188
  function resolveShadingFill(shading, theme) {
162
189
  if (!shading) return "";
163
190
  if (shading.pattern === "nil") return "";
191
+ const percentageRatio = percentageShadingRatio(shading.pattern);
192
+ if (percentageRatio !== void 0) return blendShadingColors({
193
+ foreground: resolvePatternHex({
194
+ color: shading.color,
195
+ fallback: AUTO_PATTERN_COLOR,
196
+ theme
197
+ }),
198
+ background: resolvePatternHex({
199
+ color: shading.fill,
200
+ fallback: AUTO_PATTERN_BACKGROUND,
201
+ theme
202
+ }),
203
+ ratio: percentageRatio
204
+ });
164
205
  if (shading.fill) {
165
206
  if (shading.fill.auto) return "";
166
207
  if (shading.fill.rgb === "auto" || shading.fill.rgb === "FFFFFF") return "";
167
208
  return resolveShadingColor(shading.fill, theme);
168
209
  }
169
210
  if (shading.pattern === "solid" && shading.color) return resolveShadingColor(shading.color, theme);
170
- if (shading.pattern && shading.pattern.startsWith("pct") && shading.color) return resolveShadingColor(shading.color, theme);
171
211
  return "";
172
212
  }
173
213
  /**
@@ -1,3 +1,4 @@
1
+ import { isEmptyParagraph } from "../docx/paragraphParser.js";
1
2
  //#region src/watermark/index.ts
2
3
  /**
3
4
  * Schemes allowed for a picture watermark's external image target
@@ -42,6 +43,12 @@ function setDocumentWatermark(doc, watermark) {
42
43
  const nextHeaders = /* @__PURE__ */ new Map();
43
44
  if (headers) for (const [rId, header] of headers) {
44
45
  const next = { ...header };
46
+ const retainedHostIndex = next.watermarkBlockIndex;
47
+ const retainedHost = next.rawWatermarkXml !== void 0 && retainedHostIndex !== void 0 ? next.content.at(retainedHostIndex) : void 0;
48
+ if (retainedHostIndex !== void 0 && retainedHost?.type === "paragraph" && isEmptyParagraph(retainedHost)) {
49
+ next.content = [...next.content];
50
+ next.content.splice(retainedHostIndex, 1);
51
+ }
45
52
  if (watermark === void 0) {
46
53
  delete next.watermark;
47
54
  delete next.watermarkBlockIndex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.33.2",
3
+ "version": "0.35.0",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -115,9 +115,9 @@
115
115
  "perf": "bun scripts/profile-editor.ts"
116
116
  },
117
117
  "dependencies": {
118
- "@stll/docx-core": "^0.19.1",
118
+ "@stll/docx-core": "^0.19.2",
119
119
  "@stll/docx-utils": "^0.1.0",
120
- "@stll/template-conditions": "^0.1.0",
120
+ "@stll/template-conditions": "^0.4.0",
121
121
  "better-result": "3.0.1",
122
122
  "csstype": "^3.1.3",
123
123
  "dompurify": "^3.4.13",