@stll/folio-core 0.48.0 → 0.49.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 (86) hide show
  1. package/dist/ai-edits/apply.js +565 -121
  2. package/dist/ai-edits/minimal-replacement.d.ts +64 -0
  3. package/dist/ai-edits/minimal-replacement.js +282 -0
  4. package/dist/controller/layoutPipeline.js +11 -1
  5. package/dist/display-list/build/paragraphPrimitives.js +63 -7
  6. package/dist/display-list/build/textBoxPrimitives.js +1 -1
  7. package/dist/display-list/build/unsupported.d.ts +2 -2
  8. package/dist/display-list/build/unsupported.js +0 -0
  9. package/dist/display-list/dom/renderDisplayListToDom.js +0 -1
  10. package/dist/docx/blockContentParser.js +2 -2
  11. package/dist/docx/bulletMarkers.d.ts +18 -2
  12. package/dist/docx/bulletMarkers.js +27 -2
  13. package/dist/docx/drawingGroupChildren.d.ts +60 -0
  14. package/dist/docx/drawingGroupChildren.js +176 -0
  15. package/dist/docx/drawingUtils.js +3 -0
  16. package/dist/docx/groupDrawingParser.d.ts +7 -1
  17. package/dist/docx/groupDrawingParser.js +123 -32
  18. package/dist/docx/packageParts.d.ts +45 -1
  19. package/dist/docx/packageParts.js +93 -2
  20. package/dist/docx/paraIdAttribute.d.ts +3 -2
  21. package/dist/docx/paragraphParser.js +91 -7
  22. package/dist/docx/paragraphTextBoxEnrichment.js +126 -4
  23. package/dist/docx/runParser.js +10 -0
  24. package/dist/docx/selectiveSaveFlags.d.ts +10 -3
  25. package/dist/docx/selectiveSaveFlags.js +1 -1
  26. package/dist/docx/selectiveXmlPatch.d.ts +56 -34
  27. package/dist/docx/selectiveXmlPatch.js +222 -115
  28. package/dist/docx/serializer/groupTextBoxWriteBack.d.ts +12 -0
  29. package/dist/docx/serializer/groupTextBoxWriteBack.js +101 -0
  30. package/dist/docx/serializer/paragraphSerializer.js +5 -4
  31. package/dist/docx/serializer/runSerializer.d.ts +5 -1
  32. package/dist/docx/serializer/runSerializer.js +4 -3
  33. package/dist/docx/shapeAlternateContent.d.ts +25 -0
  34. package/dist/docx/shapeAlternateContent.js +52 -0
  35. package/dist/docx/textBoxParser.d.ts +10 -2
  36. package/dist/docx/textBoxParser.js +9 -3
  37. package/dist/internal/headlessRevisionResolution.js +50 -13
  38. package/dist/layout-bridge/convert/toFlowBlocks.js +26 -4
  39. package/dist/layout-engine/index.d.ts +2 -2
  40. package/dist/layout-engine/index.js +33 -4
  41. package/dist/layout-engine/keep-together.d.ts +5 -2
  42. package/dist/layout-engine/keep-together.js +5 -1
  43. package/dist/layout-engine/measure/advanceComposition.js +49 -3
  44. package/dist/layout-engine/measure/measureBlocks.js +5 -3
  45. package/dist/layout-engine/measure/measureContainer.js +46 -3
  46. package/dist/layout-engine/measure/measureHelpers.js +13 -3
  47. package/dist/layout-engine/measure/measureParagraph.js +44 -81
  48. package/dist/layout-engine/measure/smallCapsCasing.d.ts +56 -0
  49. package/dist/layout-engine/measure/smallCapsCasing.js +88 -0
  50. package/dist/layout-engine/paginator.d.ts +2 -0
  51. package/dist/layout-engine/paginator.js +5 -1
  52. package/dist/layout-engine/paragraphSpacing.d.ts +4 -2
  53. package/dist/layout-engine/paragraphSpacing.js +2 -1
  54. package/dist/layout-engine/tableRowBreak.d.ts +8 -1
  55. package/dist/layout-engine/tableRowBreak.js +22 -1
  56. package/dist/layout-engine/types.d.ts +21 -2
  57. package/dist/layout-painter/renderParagraph.js +41 -4
  58. package/dist/layout-painter/renderTextBox.d.ts +12 -2
  59. package/dist/layout-painter/renderTextBox.js +26 -2
  60. package/dist/prosemirror/alternateContentAttrs.d.ts +9 -0
  61. package/dist/prosemirror/alternateContentAttrs.js +35 -0
  62. package/dist/prosemirror/attrs/index.js +90 -0
  63. package/dist/prosemirror/commands/comments.js +51 -41
  64. package/dist/prosemirror/contentControlRevisions.d.ts +43 -0
  65. package/dist/prosemirror/contentControlRevisions.js +84 -0
  66. package/dist/prosemirror/conversion/fromProseDoc.js +46 -22
  67. package/dist/prosemirror/conversion/toProseDoc.js +42 -19
  68. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.d.ts +7 -7
  69. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -7
  70. package/dist/prosemirror/extensions/nodes/FieldExtension.js +6 -3
  71. package/dist/prosemirror/extensions/nodes/SdtExtension.js +9 -2
  72. package/dist/prosemirror/extensions/nodes/ShapeExtension.js +1 -0
  73. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +3 -0
  74. package/dist/prosemirror/listMarker.js +2 -2
  75. package/dist/prosemirror/paragraphDirection.d.ts +29 -2
  76. package/dist/prosemirror/paragraphDirection.js +21 -2
  77. package/dist/prosemirror/plugins/suggestionMode.js +37 -8
  78. package/dist/prosemirror/rejoinRunCarriers.d.ts +27 -0
  79. package/dist/prosemirror/rejoinRunCarriers.js +66 -0
  80. package/dist/prosemirror/runIdentityAcrossRevisions.d.ts +10 -0
  81. package/dist/prosemirror/runIdentityAcrossRevisions.js +90 -0
  82. package/dist/prosemirror/schema/nodes.d.ts +37 -5
  83. package/dist/types/content.d.ts +2 -2
  84. package/package.json +2 -2
  85. package/dist/layout-engine/justifiedLineFit.d.ts +0 -7
  86. package/dist/layout-engine/justifiedLineFit.js +0 -6
@@ -0,0 +1,176 @@
1
+ import { canonicalJson } from "../utils/canonicalJson.js";
2
+ import { captureVerbatimXml } from "./verbatimCapture.js";
3
+ import { findChildByLocalName, findDeep, getAttribute, getChildElements, getLocalName, parseNumericAttribute, parseXml } from "./xmlParser.js";
4
+ //#region src/docx/drawingGroupChildren.ts
5
+ /**
6
+ * The text boxes of a DrawingML group (`wpg:wgp`), read as frames folio can lay
7
+ * out, and written back into the group's authored XML.
8
+ *
9
+ * A group states a child coordinate space (`a:chOff` / `a:chExt`) that its
10
+ * own frame (`a:off` / `a:ext`) maps onto, and every child's `a:xfrm` is in
11
+ * that space; a nested `wpg:grpSp` repeats the mapping one level down
12
+ * (ECMA-376 §20.1.7.5). Composing the mappings gives each `wps:wsp` a frame
13
+ * in EMUs relative to the group's own top-left corner. Only the frame is
14
+ * mapped: the text inside keeps its authored size and the body insets stay in
15
+ * EMUs.
16
+ */
17
+ /** `a:xfrm@rot` is in 60,000ths of a degree. */
18
+ const ROTATION_UNITS_PER_DEGREE = 6e4;
19
+ /** Deeper nesting than this is not a drawing anyone authored. */
20
+ const MAX_GROUP_DEPTH = 16;
21
+ const MAX_GROUP_TEXT_BOXES = 256;
22
+ const numberAttribute = (element, name) => parseNumericAttribute(element, null, name) ?? 0;
23
+ const onOffAttribute = (element, name) => {
24
+ const value = getAttribute(element, null, name);
25
+ return value === "1" || value === "true" || value === "on";
26
+ };
27
+ /** A `CT_GroupTransform2D` or `CT_Transform2D`; absent children read as zero. */
28
+ const readTransform = (xfrm) => {
29
+ const offset = findChildByLocalName(xfrm, "off");
30
+ const extent = findChildByLocalName(xfrm, "ext");
31
+ const childOffset = findChildByLocalName(xfrm, "chOff");
32
+ const childExtent = findChildByLocalName(xfrm, "chExt");
33
+ return {
34
+ x: numberAttribute(offset, "x"),
35
+ y: numberAttribute(offset, "y"),
36
+ width: numberAttribute(extent, "cx"),
37
+ height: numberAttribute(extent, "cy"),
38
+ childX: numberAttribute(childOffset, "x"),
39
+ childY: numberAttribute(childOffset, "y"),
40
+ childWidth: numberAttribute(childExtent, "cx"),
41
+ childHeight: numberAttribute(childExtent, "cy"),
42
+ rotation: numberAttribute(xfrm, "rot") / ROTATION_UNITS_PER_DEGREE,
43
+ flipH: onOffAttribute(xfrm, "flipH"),
44
+ flipV: onOffAttribute(xfrm, "flipV")
45
+ };
46
+ };
47
+ const groupTransform = (group) => readTransform(findChildByLocalName(findChildByLocalName(group, "grpSpPr"), "xfrm"));
48
+ const shapeTransform = (shape) => readTransform(findChildByLocalName(findChildByLocalName(shape, "spPr"), "xfrm"));
49
+ /**
50
+ * The top-level mapping: the group's child space stretched over the drawing's
51
+ * extent. A missing or empty child extent means the child space is the
52
+ * drawing's own.
53
+ */
54
+ const rootMap = (group, width, height) => {
55
+ const transform = groupTransform(group);
56
+ const scaleX = transform.childWidth > 0 ? width / transform.childWidth : 1;
57
+ const scaleY = transform.childHeight > 0 ? height / transform.childHeight : 1;
58
+ return {
59
+ scaleX,
60
+ scaleY,
61
+ offsetX: -transform.childX * scaleX,
62
+ offsetY: -transform.childY * scaleY
63
+ };
64
+ };
65
+ /** A nested group's child space, mapped through its frame in the parent's space. */
66
+ const nestedMap = (parent, transform) => {
67
+ const scaleX = transform.childWidth > 0 ? transform.width / transform.childWidth : 1;
68
+ const scaleY = transform.childHeight > 0 ? transform.height / transform.childHeight : 1;
69
+ return {
70
+ scaleX: parent.scaleX * scaleX,
71
+ scaleY: parent.scaleY * scaleY,
72
+ offsetX: parent.scaleX * (transform.x - transform.childX * scaleX) + parent.offsetX,
73
+ offsetY: parent.scaleY * (transform.y - transform.childY * scaleY) + parent.offsetY
74
+ };
75
+ };
76
+ const hasTextBox = (wsp) => findChildByLocalName(findChildByLocalName(wsp, "txbx"), "txbxContent") !== null;
77
+ /**
78
+ * Every `wps:wsp` with a text box in the group, nested groups included, with
79
+ * its frame in EMUs from the group's top-left corner.
80
+ *
81
+ * A nested group that is rotated or flipped turns its children's frames into
82
+ * something other than a rectangle in the drawing's axes, so its text boxes
83
+ * are left out and stay with the preview.
84
+ */
85
+ const collectGroupTextBoxes = (group, width, height) => {
86
+ const frames = [];
87
+ const visit = (container, map, path, depth) => {
88
+ for (const [index, child] of getChildElements(container).entries()) {
89
+ if (frames.length >= MAX_GROUP_TEXT_BOXES) return;
90
+ const localName = getLocalName(child.name ?? "");
91
+ if (localName === "grpSp" && depth < MAX_GROUP_DEPTH) {
92
+ const transform = groupTransform(child);
93
+ if (transform.rotation === 0 && !transform.flipH && !transform.flipV) visit(child, nestedMap(map, transform), [...path, index], depth + 1);
94
+ continue;
95
+ }
96
+ if (localName !== "wsp" || !hasTextBox(child)) continue;
97
+ const transform = shapeTransform(child);
98
+ const frameWidth = transform.width * map.scaleX;
99
+ const frameHeight = transform.height * map.scaleY;
100
+ if (!(frameWidth > 0 && frameHeight > 0)) continue;
101
+ frames.push({
102
+ path: [...path, index],
103
+ wsp: child,
104
+ x: transform.x * map.scaleX + map.offsetX,
105
+ y: transform.y * map.scaleY + map.offsetY,
106
+ width: frameWidth,
107
+ height: frameHeight,
108
+ rotation: transform.rotation,
109
+ flipH: transform.flipH,
110
+ flipV: transform.flipV
111
+ });
112
+ }
113
+ };
114
+ visit(group, rootMap(group, width, height), [], 0);
115
+ return frames;
116
+ };
117
+ /** Two FNV-1a passes with different offsets: a short, stable identity. */
118
+ const fingerprint = (text) => {
119
+ let first = 2166136261;
120
+ let second = 16777619;
121
+ for (let index = 0; index < text.length; index += 1) {
122
+ const code = text.charCodeAt(index);
123
+ first = Math.imul(first ^ code, 16777619);
124
+ second = Math.imul(second ^ code, 96280213);
125
+ }
126
+ return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0).toString(16).padStart(8, "0")}`;
127
+ };
128
+ /** Identity of a group drawing's authored XML. */
129
+ const groupXmlFingerprint = (rawXml) => fingerprint(rawXml);
130
+ /** Identity of a text box's content, compared on save to tell an edit. */
131
+ const groupTextContentFingerprint = (content) => fingerprint(canonicalJson(content));
132
+ /** The group a captured drawing carries; a Fallback branch holds none. */
133
+ const findGroup = (root) => findDeep(root, "wpg", "wgp") ?? void 0;
134
+ const childAtPath = (group, path) => {
135
+ let current = group;
136
+ for (const index of path) current = current ? getChildElements(current)[index] : void 0;
137
+ return current;
138
+ };
139
+ /**
140
+ * The text box content element a path names, or undefined when the path does
141
+ * not lead to a `wps:wsp` holding one.
142
+ */
143
+ const textBoxContentAt = (group, path) => {
144
+ const wsp = childAtPath(group, path);
145
+ if (!wsp || getLocalName(wsp.name ?? "") !== "wsp") return;
146
+ return findChildByLocalName(findChildByLocalName(wsp, "txbx"), "txbxContent") ?? void 0;
147
+ };
148
+ /**
149
+ * The captured group XML with each edited text box's content replaced, or
150
+ * undefined when an edit names no text box in it.
151
+ *
152
+ * The replacement is written as a placeholder element and substituted after
153
+ * the tree is serialized, so the new content is never reparsed and the rest
154
+ * of the capture is written exactly as it was read.
155
+ */
156
+ const replaceGroupTextBoxContent = (rawXml, edits) => {
157
+ const root = parseXml(rawXml);
158
+ const group = findGroup(root);
159
+ if (!group) return;
160
+ const replacements = /* @__PURE__ */ new Map();
161
+ for (const [index, edit] of edits.entries()) {
162
+ const content = textBoxContentAt(group, edit.path);
163
+ if (!content) return;
164
+ const placeholder = `folio-group-text-${index}-${fingerprint(rawXml)}`;
165
+ content.elements = [{
166
+ type: "element",
167
+ name: placeholder
168
+ }];
169
+ replacements.set(`<${placeholder}/>`, edit.contentXml || "<w:p/>");
170
+ }
171
+ let xml = (root.elements ?? []).map((element) => captureVerbatimXml(element)).join("");
172
+ for (const [placeholder, contentXml] of replacements) xml = xml.replace(placeholder, () => contentXml);
173
+ return xml;
174
+ };
175
+ //#endregion
176
+ export { collectGroupTextBoxes, groupTextContentFingerprint, groupXmlFingerprint, readTransform, replaceGroupTextBoxContent };
@@ -107,6 +107,7 @@ function parseFill(spPr) {
107
107
  function parseGradientFill(gradientFill) {
108
108
  let type = "linear";
109
109
  let angle;
110
+ let scaled;
110
111
  const linear = findChildByLocalName(gradientFill, "lin");
111
112
  if (linear) {
112
113
  const authoredAngle = getAttribute(linear, null, "ang");
@@ -114,6 +115,7 @@ function parseGradientFill(gradientFill) {
114
115
  const parsedAngle = Number.parseInt(authoredAngle, 10);
115
116
  angle = Number.isNaN(parsedAngle) ? void 0 : parsedAngle / 6e4;
116
117
  }
118
+ scaled = parseOnOffValue(getAttribute(linear, null, "scaled"), void 0, "a:lin@scaled");
117
119
  }
118
120
  const path = findChildByLocalName(gradientFill, "path");
119
121
  if (path) {
@@ -139,6 +141,7 @@ function parseGradientFill(gradientFill) {
139
141
  gradient: {
140
142
  type,
141
143
  ...angle !== void 0 ? { angle } : {},
144
+ ...scaled !== void 0 ? { scaled } : {},
142
145
  stops
143
146
  }
144
147
  };
@@ -1,7 +1,13 @@
1
1
  import { document_d_exports } from "../types/document.js";
2
2
  import { XmlElement } from "./xmlParser.js";
3
3
  import { PreviewLedger } from "./previewBudget.js";
4
+ import { GroupTextBoxFrame } from "./drawingGroupChildren.js";
4
5
  //#region src/docx/groupDrawingParser.d.ts
6
+ /**
7
+ * The text boxes a group preview does not draw, which the paragraph parser
8
+ * lifts out as text boxes of their own. Undefined for any other image.
9
+ */
10
+ declare const groupPreviewTextBoxes: (image: document_d_exports.Image) => readonly GroupTextBoxFrame[] | undefined;
5
11
  /**
6
12
  * Whether a `w:drawing` carries a WordprocessingGroup payload. A group this
7
13
  * module declines to rasterize has no editable projection either — the shape
@@ -11,4 +17,4 @@ declare const isGroupDrawing: (drawing: XmlElement) => boolean;
11
17
  /** Parse a WordprocessingGroup drawing into a safe SVG-backed image preview. */
12
18
  declare const parseGroupDrawing: (drawing: XmlElement, previews: PreviewLedger, rels?: document_d_exports.RelationshipMap, media?: Map<string, document_d_exports.MediaFile>) => document_d_exports.Image | null;
13
19
  //#endregion
14
- export { isGroupDrawing, parseGroupDrawing };
20
+ export { groupPreviewTextBoxes, isGroupDrawing, parseGroupDrawing };
@@ -1,4 +1,5 @@
1
1
  import { emuToPixels } from "../utils/units.js";
2
+ import { collectGroupTextBoxes, readTransform } from "./drawingGroupChildren.js";
2
3
  import { parseImage, resolveImageData } from "./imageParser.js";
3
4
  import { findAllDeep, findChildByLocalName, findChildrenByLocalName, getAttribute, getChildElements, getLocalName, getTextContent, parseNumericAttribute } from "./xmlParser.js";
4
5
  import { escapeXmlAttribute, escapeXmlText } from "@stll/docx-core";
@@ -9,6 +10,7 @@ const DEFAULT_FONT_HALF_POINTS = 22;
9
10
  const DEFAULT_LINE_WIDTH_EMU = 9525;
10
11
  const HALF_POINT_TO_EMU = 6350;
11
12
  const MAX_GROUP_SHAPES = 256;
13
+ const MAX_GROUP_DEPTH = 16;
12
14
  const CROP_SCALE = 1e5;
13
15
  const MAX_PATH_COMMANDS = 1e4;
14
16
  const MAX_TEXT_CHARACTERS = 2e4;
@@ -79,34 +81,66 @@ const pathData = (path) => {
79
81
  }
80
82
  return commands.join(" ");
81
83
  };
82
- const renderGeometry = (wsp) => {
84
+ /**
85
+ * A child's own `a:xfrm` rotation and flips, applied about its centre in the
86
+ * coordinate space it is drawn in (ECMA-376 §20.1.7.6).
87
+ */
88
+ const withChildTransform = (element, piece) => {
89
+ if (!piece) return piece;
90
+ const transform = readTransform(findChildByLocalName(findChildByLocalName(element, "spPr"), "xfrm"));
91
+ return wrapTransformed(transform, piece);
92
+ };
93
+ const wrapTransformed = (transform, piece) => {
94
+ if (transform.rotation === 0 && !transform.flipH && !transform.flipV) return piece;
95
+ const centerX = transform.x + transform.width / 2;
96
+ const centerY = transform.y + transform.height / 2;
97
+ const operations = [];
98
+ if (transform.rotation !== 0) operations.push(`rotate(${transform.rotation} ${centerX} ${centerY})`);
99
+ if (transform.flipH || transform.flipV) operations.push(`translate(${centerX} ${centerY}) scale(${transform.flipH ? -1 : 1} ${transform.flipV ? -1 : 1}) translate(${-centerX} ${-centerY})`);
100
+ return `<g transform="${operations.join(" ")}">${piece}</g>`;
101
+ };
102
+ /**
103
+ * `unitScale` is how many EMUs one unit of the child coordinate space spans.
104
+ * Line widths are authored in EMUs, so they are divided by it to be drawn in
105
+ * the space the geometry is in.
106
+ */
107
+ const renderGeometry = (wsp, unitScale) => {
83
108
  const { x, y, width, height } = childTransform(wsp);
84
- if (width <= 0 || height <= 0) return "";
85
109
  const spPr = findChildByLocalName(wsp, "spPr");
86
110
  const style = findChildByLocalName(wsp, "style");
87
111
  const styleStroke = colorFrom(findChildByLocalName(style, "lnRef"), "none");
88
112
  const fill = colorFrom(spPr, "none");
89
113
  const line = findChildByLocalName(spPr, "ln");
90
114
  const stroke = colorFrom(line, styleStroke);
91
- const strokeWidth = line ? parseNumericAttribute(line, null, "w") ?? DEFAULT_LINE_WIDTH_EMU : 0;
115
+ const strokeWidth = (line ? parseNumericAttribute(line, null, "w") ?? DEFAULT_LINE_WIDTH_EMU : 0) / unitScale;
116
+ const strokeColor = stroke === "none" ? "none" : `#${stroke}`;
117
+ if (width <= 0 || height <= 0) {
118
+ if (width < 0 || height < 0 || width === 0 && height === 0 || strokeColor === "none") return "";
119
+ return `<line x1="${x}" y1="${y}" x2="${x + width}" y2="${y + height}" stroke="${strokeColor}" stroke-width="${strokeWidth}"/>`;
120
+ }
92
121
  const paths = findAllDeep(findChildByLocalName(spPr, "custGeom"), "a", "path");
93
- if (paths.length === 0) return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${fill === "none" ? "none" : `#${fill}`}" stroke="${stroke === "none" ? "none" : `#${stroke}`}" stroke-width="${strokeWidth}"/>`;
122
+ if (paths.length === 0) return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${fill === "none" ? "none" : `#${fill}`}" stroke="${strokeColor}" stroke-width="${strokeWidth}"/>`;
94
123
  return paths.map((path) => {
95
124
  const viewWidth = numericAttr(path, "w") || width;
96
125
  const viewHeight = numericAttr(path, "h") || height;
97
126
  const d = pathData(path);
98
127
  if (!d) return "";
99
- return `<path d="${d}" transform="translate(${x} ${y}) scale(${width / viewWidth} ${height / viewHeight})" fill="${fill === "none" ? "none" : `#${fill}`}" stroke="${stroke === "none" ? "none" : `#${stroke}`}" stroke-width="${strokeWidth}"/>`;
128
+ return `<path d="${d}" transform="translate(${x} ${y}) scale(${width / viewWidth} ${height / viewHeight})" fill="${fill === "none" ? "none" : `#${fill}`}" stroke="${strokeColor}" stroke-width="${strokeWidth}"/>`;
100
129
  }).join("");
101
130
  };
102
- const renderTextBox = (wsp) => {
131
+ /**
132
+ * An approximate rendering of a text box the layout does not host (an inline
133
+ * group, or one placed by alignment rather than offset). Font sizes are in
134
+ * EMUs and the drawing is in child units, hence `unitScale`.
135
+ */
136
+ const renderTextBox = (wsp, unitScale) => {
103
137
  const { x, y, width, height } = childTransform(wsp);
104
138
  if (width <= 0 || height <= 0) return "";
105
139
  const paragraphs = findAllDeep(wsp, "w", "p");
106
140
  const firstSize = findAllDeep(wsp, "w", "sz").at(0);
107
141
  const halfPoints = numericAttr(firstSize ?? null, "val") || DEFAULT_FONT_HALF_POINTS;
108
142
  const color = colorFrom(findAllDeep(wsp, "w", "color").at(0) ?? null, DEFAULT_TEXT_COLOR);
109
- const fontSize = halfPoints * HALF_POINT_TO_EMU;
143
+ const fontSize = halfPoints * HALF_POINT_TO_EMU / unitScale;
110
144
  const maxCharacters = Math.max(1, Math.floor(width / (fontSize * .38)));
111
145
  const lines = paragraphs.flatMap((paragraph) => wrapLine(getTextContent(paragraph).slice(0, MAX_TEXT_CHARACTERS), maxCharacters)).map(escapeXmlText);
112
146
  if (lines.length === 0) return "";
@@ -137,38 +171,92 @@ const renderPicture = (picture, index, rels, media) => {
137
171
  return `<defs><clipPath id="${clipId}"><rect x="${x}" y="${y}" width="${width}" height="${height}"/></clipPath></defs><g clip-path="url(#${clipId})">${image}</g>`;
138
172
  };
139
173
  const groupViewBox = (group, width, height) => {
140
- const transform = findChildByLocalName(findChildByLocalName(group, "grpSpPr"), "xfrm");
141
- const childOffset = findChildByLocalName(transform, "chOff");
142
- const childExtent = findChildByLocalName(transform, "chExt");
143
- const childWidth = numericAttr(childExtent, "cx");
144
- const childHeight = numericAttr(childExtent, "cy");
174
+ const transform = readTransform(findChildByLocalName(findChildByLocalName(group, "grpSpPr"), "xfrm"));
145
175
  return {
146
- x: numericAttr(childOffset, "x"),
147
- y: numericAttr(childOffset, "y"),
148
- width: childWidth > 0 ? childWidth : width,
149
- height: childHeight > 0 ? childHeight : height
176
+ x: transform.childX,
177
+ y: transform.childY,
178
+ width: transform.childWidth > 0 ? transform.childWidth : width,
179
+ height: transform.childHeight > 0 ? transform.childHeight : height
150
180
  };
151
181
  };
152
- const createSvg = (group, width, height, rels, media) => {
153
- const children = getChildElements(group).slice(0, MAX_GROUP_SHAPES);
154
- let content = "";
155
- for (const [index, child] of children.entries()) {
156
- const localName = getLocalName(child.name ?? "");
157
- let piece;
158
- if (localName === "pic") piece = renderPicture(child, index, rels, media);
159
- else if (localName === "wsp") piece = findChildByLocalName(child, "txbx") ? renderTextBox(child) : renderGeometry(child);
160
- else continue;
161
- content += piece;
162
- if (content.length > MAX_SVG_CHARACTERS) return null;
163
- }
164
- if (!content) return null;
182
+ /**
183
+ * A nested group's child space drawn inside its frame in the parent's space,
184
+ * then turned by the group's own rotation and flips.
185
+ */
186
+ const nestedGroupTransform = (transform) => {
187
+ const scaleX = transform.childWidth > 0 ? transform.width / transform.childWidth : 1;
188
+ const scaleY = transform.childHeight > 0 ? transform.height / transform.childHeight : 1;
189
+ return `translate(${transform.x} ${transform.y}) scale(${scaleX} ${scaleY}) translate(${-transform.childX} ${-transform.childY})`;
190
+ };
191
+ const averageScale = (scale) => (scale.x + scale.y) / 2 || 1;
192
+ const createSvg = (group, width, height, rels, media, hostedTextBoxes) => {
193
+ let total = 0;
194
+ let shapes = 0;
195
+ let serial = 0;
196
+ /** One container's markup, or null once the budget is spent. */
197
+ const render = (container, scale, depth) => {
198
+ let content = "";
199
+ for (const child of getChildElements(container)) {
200
+ const index = serial;
201
+ serial += 1;
202
+ if (shapes >= MAX_GROUP_SHAPES) break;
203
+ const localName = getLocalName(child.name ?? "");
204
+ let piece;
205
+ if (localName === "pic") {
206
+ shapes += 1;
207
+ piece = withChildTransform(child, renderPicture(child, index, rels, media));
208
+ } else if (localName === "wsp") {
209
+ shapes += 1;
210
+ if (hostedTextBoxes.has(child)) continue;
211
+ const unitScale = averageScale(scale);
212
+ piece = withChildTransform(child, findChildByLocalName(child, "txbx") ? `${renderGeometry(child, unitScale)}${renderTextBox(child, unitScale)}` : renderGeometry(child, unitScale));
213
+ } else if (localName === "grpSp" && depth < MAX_GROUP_DEPTH) {
214
+ const transform = readTransform(findChildByLocalName(findChildByLocalName(child, "grpSpPr"), "xfrm"));
215
+ const scaleX = transform.childWidth > 0 ? transform.width / transform.childWidth : 1;
216
+ const scaleY = transform.childHeight > 0 ? transform.height / transform.childHeight : 1;
217
+ const nested = render(child, {
218
+ x: scale.x * scaleX,
219
+ y: scale.y * scaleY
220
+ }, depth + 1);
221
+ if (nested === null) return null;
222
+ piece = nested ? wrapTransformed(transform, `<g transform="${nestedGroupTransform(transform)}">${nested}</g>`) : "";
223
+ total -= nested.length;
224
+ } else continue;
225
+ content += piece;
226
+ total += piece.length;
227
+ if (total > MAX_SVG_CHARACTERS) return null;
228
+ }
229
+ return content;
230
+ };
165
231
  const viewBox = groupViewBox(group, width, height);
232
+ const content = render(group, {
233
+ x: width / viewBox.width,
234
+ y: height / viewBox.height
235
+ }, 0);
236
+ if (content === null || !content && hostedTextBoxes.size === 0) return null;
166
237
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}" width="${emuToPixels(width)}" height="${emuToPixels(height)}">${content}</svg>`;
167
238
  };
168
239
  const groupElement = (drawing) => {
169
240
  return findChildByLocalName(findAllDeep(drawing, "a", "graphicData").at(0) ?? null, "wgp");
170
241
  };
171
242
  /**
243
+ * Whether the layout can place the group's text boxes itself. A floating group
244
+ * positioned by offsets on both axes puts each child at the group's offset
245
+ * plus the child's frame, which a text box anchored beside the group can state
246
+ * exactly; an inline group or one placed by alignment has no such offset.
247
+ */
248
+ const hostsTextBoxes = (image) => {
249
+ const position = image.position;
250
+ return image.wrap.type !== "inline" && image.anchor?.useSimplePosition !== true && position !== void 0 && position.horizontal.alignment === void 0 && position.horizontal.posOffset !== void 0 && position.vertical.alignment === void 0 && position.vertical.posOffset !== void 0;
251
+ };
252
+ /** The text boxes each group preview leaves to the layout, keyed by the preview. */
253
+ const hostedTextBoxFrames = /* @__PURE__ */ new WeakMap();
254
+ /**
255
+ * The text boxes a group preview does not draw, which the paragraph parser
256
+ * lifts out as text boxes of their own. Undefined for any other image.
257
+ */
258
+ const groupPreviewTextBoxes = (image) => hostedTextBoxFrames.get(image);
259
+ /**
172
260
  * Whether a `w:drawing` carries a WordprocessingGroup payload. A group this
173
261
  * module declines to rasterize has no editable projection either — the shape
174
262
  * model holds one shape, not a group — so the caller must preserve it raw.
@@ -180,10 +268,13 @@ const parseGroupDrawing = (drawing, previews, rels, media) => {
180
268
  if (!group) return null;
181
269
  const image = parseImage(drawing, void 0, void 0);
182
270
  if (!image || image.size.width <= 0 || image.size.height <= 0) return null;
183
- const svg = createSvg(group, image.size.width, image.size.height, rels, media);
271
+ const hosted = hostsTextBoxes(image) ? collectGroupTextBoxes(group, image.size.width, image.size.height) : [];
272
+ const svg = createSvg(group, image.size.width, image.size.height, rels, media, new Set(hosted.map(({ wsp }) => wsp)));
184
273
  if (!svg || svg.length > MAX_SVG_CHARACTERS) return null;
185
274
  const { rId: _rId, src: _src, mimeType: _mimeType, filename: _filename, ...frame } = image;
186
- return previews.svgImage("wpGroup", svg, frame);
275
+ const preview = previews.svgImage("wpGroup", svg, frame);
276
+ if (hosted.length > 0) hostedTextBoxFrames.set(preview, hosted);
277
+ return preview;
187
278
  };
188
279
  //#endregion
189
- export { isGroupDrawing, parseGroupDrawing };
280
+ export { groupPreviewTextBoxes, isGroupDrawing, parseGroupDrawing };
@@ -19,6 +19,16 @@ type PackageReferenceRepair = {
19
19
  danglingRelationships: string[];
20
20
  /** Parts a content-type override named that the package does not hold. */
21
21
  danglingOverrides: string[];
22
+ /**
23
+ * `<part>|<id>` pairs: an `r:*`-style attribute in `part` named a
24
+ * relationship id that reconciliation just removed from `part`'s own
25
+ * `.rels` (because that relationship's target was itself a dangling
26
+ * reference). Dropping the relationship without also dropping the
27
+ * attribute that names it would trade one dangling reference for another —
28
+ * a `.rels` entry pointing nowhere becomes a content attribute pointing at
29
+ * nothing in its own `.rels`, which is no more valid.
30
+ */
31
+ orphanedIdReferences: string[];
22
32
  };
23
33
  /**
24
34
  * Make the package internally consistent before it is written: every internal
@@ -31,5 +41,39 @@ type PackageReferenceRepair = {
31
41
  * which is what the repack tests assert about their own output.
32
42
  */
33
43
  declare const reconcilePackageReferences: (zip: JSZip, compressionLevel: number) => Promise<PackageReferenceRepair>;
44
+ /**
45
+ * What {@link checkPackageIntegrity} found wrong with a package. Both fields
46
+ * are empty for a package where every internal relationship resolves and
47
+ * every reference to one does too — the state {@link reconcilePackageReferences}
48
+ * is meant to leave behind, and the state a save must never regress from.
49
+ */
50
+ type PackageIntegrityReport = {
51
+ /**
52
+ * `<part>|<id>` pairs: an `r:*`-style attribute in `part` names a
53
+ * relationship id absent from `part`'s own `.rels` — the id was never
54
+ * declared, or a repair (this module's or any other) dropped it without
55
+ * also dropping the attribute that names it.
56
+ */
57
+ unresolvedReferences: string[];
58
+ /**
59
+ * `<relsPart>|<target>` pairs: an internal (non-external) relationship in
60
+ * `relsPart` names a target the package does not hold.
61
+ */
62
+ danglingRelationshipTargets: string[];
63
+ };
64
+ /**
65
+ * Verify the two invariants an OPC package must hold for a conforming
66
+ * consumer to accept it: every `r:*` reference in an XML part resolves in
67
+ * that part's own `.rels`, and every internal relationship target names a
68
+ * part the package actually holds.
69
+ *
70
+ * A read-only counterpart to {@link reconcilePackageReferences} — that
71
+ * function repairs a package by removing what does not resolve; this one
72
+ * only reports what still does not, so a test can assert a save produced
73
+ * nothing for it to find. Cheap enough to run after every repack in a test
74
+ * or a corpus-gate invariant: one pass over the `.rels` parts building an id
75
+ * census, one pass over the XML parts checking references against it.
76
+ */
77
+ declare const checkPackageIntegrity: (zip: JSZip) => Promise<PackageIntegrityReport>;
34
78
  //#endregion
35
- export { PackageReferenceRepair, isUnsafePackagePath, reconcilePackageReferences, removeUnsafeEntries };
79
+ export { PackageIntegrityReport, PackageReferenceRepair, checkPackageIntegrity, isUnsafePackagePath, reconcilePackageReferences, removeUnsafeEntries };
@@ -3,9 +3,25 @@ import { resolveRelativePath } from "./relsParser.js";
3
3
  const CONTENT_TYPE_ELEMENT = /<(?:Default|Override)\b[^>]*?\/?>/giu;
4
4
  const PART_NAME_ATTRIBUTE = /\bPartName\s*=\s*(?<quote>["'])(?<value>[^"']*)\k<quote>/u;
5
5
  const RELATIONSHIP_ELEMENT = /<Relationship\b[^>]*?(?:\/>|>\s*<\/Relationship>)/giu;
6
+ const RELATIONSHIP_ID_ATTRIBUTE = /\bId\s*=\s*(?<quote>["'])(?<value>[^"']*)\k<quote>/u;
6
7
  const TARGET_ATTRIBUTE = /\bTarget\s*=\s*(?<quote>["'])(?<value>[^"']*)\k<quote>/u;
7
8
  const TARGET_MODE_ATTRIBUTE = /\bTargetMode\s*=\s*(?<quote>["'])(?<value>[^"']*)\k<quote>/u;
8
9
  const CONTENT_TYPES_PATTERN = /^\[Content_Types\]\.xml$/iu;
10
+ /** Escape a string for literal use inside a `RegExp`. */
11
+ const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
12
+ /**
13
+ * The XML part a `.rels` part describes: `word/_rels/header1.xml.rels` ->
14
+ * `word/header1.xml`; the package-level `_rels/.rels` has no single owning
15
+ * part (its relationships are resolved by type, never by `r:id`), so it maps
16
+ * to `undefined`.
17
+ */
18
+ const owningPartPath = (relsPath) => {
19
+ const slash = relsPath.lastIndexOf("/_rels/");
20
+ if (slash === -1) return;
21
+ const directory = relsPath.slice(0, slash);
22
+ const fileName = relsPath.slice(slash + 7).replace(/\.rels$/iu, "");
23
+ return fileName ? `${directory ? `${directory}/` : ""}${fileName}` : void 0;
24
+ };
9
25
  /** Normalize a package path for comparison: lower case, no leading slash. */
10
26
  const normalizePartPath = (path) => (path.startsWith("/") ? path.slice(1) : path).toLowerCase();
11
27
  /**
@@ -80,8 +96,10 @@ const reconcilePackageReferences = async (zip, compressionLevel) => {
80
96
  const present = presentPartPaths(zip);
81
97
  const repair = {
82
98
  danglingRelationships: [],
83
- danglingOverrides: []
99
+ danglingOverrides: [],
100
+ orphanedIdReferences: []
84
101
  };
102
+ const removedIdsByOwningPart = /* @__PURE__ */ new Map();
85
103
  for (const { path, xml } of await readRelationshipParts(zip)) {
86
104
  let dropped = false;
87
105
  const pruned = xml.replaceAll(RELATIONSHIP_ELEMENT, (element) => {
@@ -89,6 +107,13 @@ const reconcilePackageReferences = async (zip, compressionLevel) => {
89
107
  if (target === null || isPresent(present, target)) return element;
90
108
  repair.danglingRelationships.push(target);
91
109
  dropped = true;
110
+ const id = RELATIONSHIP_ID_ATTRIBUTE.exec(element)?.groups?.["value"];
111
+ const owningPart = owningPartPath(path);
112
+ if (id !== void 0 && owningPart !== void 0) {
113
+ const ids = removedIdsByOwningPart.get(owningPart) ?? /* @__PURE__ */ new Set();
114
+ ids.add(id);
115
+ removedIdsByOwningPart.set(owningPart, ids);
116
+ }
92
117
  return "";
93
118
  });
94
119
  if (dropped) writePart({
@@ -98,6 +123,28 @@ const reconcilePackageReferences = async (zip, compressionLevel) => {
98
123
  compressionLevel
99
124
  });
100
125
  }
126
+ for (const [partPath, ids] of removedIdsByOwningPart) {
127
+ const file = zip.file(partPath);
128
+ if (!file) continue;
129
+ const idPattern = [...ids].map((id) => escapeRegExp(id)).join("|");
130
+ const referencePattern = new RegExp(`\\s+r:[A-Za-z]+\\s*=\\s*(?<quote>["'])(?<id>${idPattern})\\k<quote>`, "gu");
131
+ const xml = await file.async("text");
132
+ const removedIds = /* @__PURE__ */ new Set();
133
+ const scrubbed = xml.replaceAll(referencePattern, (...args) => {
134
+ const groups = args.at(-1);
135
+ if (groups?.id !== void 0) removedIds.add(groups.id);
136
+ return "";
137
+ });
138
+ if (removedIds.size > 0) {
139
+ writePart({
140
+ zip,
141
+ path: partPath,
142
+ xml: scrubbed,
143
+ compressionLevel
144
+ });
145
+ for (const id of removedIds) repair.orphanedIdReferences.push(`${partPath}|${id}`);
146
+ }
147
+ }
101
148
  const contentTypes = zip.file(CONTENT_TYPES_PATTERN)[0];
102
149
  if (!contentTypes) return repair;
103
150
  let droppedOverride = false;
@@ -116,5 +163,49 @@ const reconcilePackageReferences = async (zip, compressionLevel) => {
116
163
  });
117
164
  return repair;
118
165
  };
166
+ /** Every `r:*`-style attribute value found anywhere in an XML part's text. */
167
+ const REFERENCE_ATTRIBUTE = /\br:[A-Za-z]+\s*=\s*(?<quote>["'])(?<id>[^"']*)\k<quote>/gu;
168
+ /**
169
+ * Verify the two invariants an OPC package must hold for a conforming
170
+ * consumer to accept it: every `r:*` reference in an XML part resolves in
171
+ * that part's own `.rels`, and every internal relationship target names a
172
+ * part the package actually holds.
173
+ *
174
+ * A read-only counterpart to {@link reconcilePackageReferences} — that
175
+ * function repairs a package by removing what does not resolve; this one
176
+ * only reports what still does not, so a test can assert a save produced
177
+ * nothing for it to find. Cheap enough to run after every repack in a test
178
+ * or a corpus-gate invariant: one pass over the `.rels` parts building an id
179
+ * census, one pass over the XML parts checking references against it.
180
+ */
181
+ const checkPackageIntegrity = async (zip) => {
182
+ const present = presentPartPaths(zip);
183
+ const report = {
184
+ unresolvedReferences: [],
185
+ danglingRelationshipTargets: []
186
+ };
187
+ const idsByOwningPart = /* @__PURE__ */ new Map();
188
+ for (const { path, xml } of await readRelationshipParts(zip)) {
189
+ const owningPart = owningPartPath(path);
190
+ const ids = /* @__PURE__ */ new Set();
191
+ for (const element of xml.match(RELATIONSHIP_ELEMENT) ?? []) {
192
+ const id = RELATIONSHIP_ID_ATTRIBUTE.exec(element)?.groups?.["value"];
193
+ if (id !== void 0) ids.add(id);
194
+ const target = relationshipTarget(element, path);
195
+ if (target !== null && !isPresent(present, target)) report.danglingRelationshipTargets.push(`${path}|${target}`);
196
+ }
197
+ if (owningPart !== void 0) idsByOwningPart.set(owningPart, ids);
198
+ }
199
+ for (const [path, file] of Object.entries(zip.files)) {
200
+ if (file.dir || !normalizePartPath(path).endsWith(".xml")) continue;
201
+ const xml = await file.async("text");
202
+ const ids = idsByOwningPart.get(path);
203
+ for (const match of xml.matchAll(REFERENCE_ATTRIBUTE)) {
204
+ const id = match.groups?.["id"];
205
+ if (id !== void 0 && !ids?.has(id)) report.unresolvedReferences.push(`${path}|${id}`);
206
+ }
207
+ }
208
+ return report;
209
+ };
119
210
  //#endregion
120
- export { isUnsafePackagePath, reconcilePackageReferences, removeUnsafeEntries };
211
+ export { checkPackageIntegrity, isUnsafePackagePath, reconcilePackageReferences, removeUnsafeEntries };
@@ -85,8 +85,9 @@ declare const alignParagraphOrdinals: (base: readonly ParagraphIdentity[], revis
85
85
  * describe one document — the source part and the model parsed from it — so
86
86
  * {@link alignParagraphOrdinals}'s `consistent` is the whole of the question: a
87
87
  * package with ids on some paragraphs proves its own alignment through them,
88
- * and one with none is aligned vacuously behind the paragraph-count check the
89
- * caller already runs.
88
+ * and one with none is aligned exactly when the two sequences have the same
89
+ * length. The save asks this per story (main flow, text boxes), so a text box
90
+ * the model re-reads cannot misalign the main flow.
90
91
  */
91
92
  type ParagraphIdentityPlan = {
92
93
  identities: readonly ParagraphIdentity[];