@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.
- package/dist/ai-edits/apply.js +565 -121
- package/dist/ai-edits/minimal-replacement.d.ts +64 -0
- package/dist/ai-edits/minimal-replacement.js +282 -0
- package/dist/controller/layoutPipeline.js +11 -1
- package/dist/display-list/build/paragraphPrimitives.js +63 -7
- package/dist/display-list/build/textBoxPrimitives.js +1 -1
- package/dist/display-list/build/unsupported.d.ts +2 -2
- package/dist/display-list/build/unsupported.js +0 -0
- package/dist/display-list/dom/renderDisplayListToDom.js +0 -1
- package/dist/docx/blockContentParser.js +2 -2
- package/dist/docx/bulletMarkers.d.ts +18 -2
- package/dist/docx/bulletMarkers.js +27 -2
- package/dist/docx/drawingGroupChildren.d.ts +60 -0
- package/dist/docx/drawingGroupChildren.js +176 -0
- package/dist/docx/drawingUtils.js +3 -0
- package/dist/docx/groupDrawingParser.d.ts +7 -1
- package/dist/docx/groupDrawingParser.js +123 -32
- package/dist/docx/packageParts.d.ts +45 -1
- package/dist/docx/packageParts.js +93 -2
- package/dist/docx/paraIdAttribute.d.ts +3 -2
- package/dist/docx/paragraphParser.js +91 -7
- package/dist/docx/paragraphTextBoxEnrichment.js +126 -4
- package/dist/docx/runParser.js +10 -0
- package/dist/docx/selectiveSaveFlags.d.ts +10 -3
- package/dist/docx/selectiveSaveFlags.js +1 -1
- package/dist/docx/selectiveXmlPatch.d.ts +56 -34
- package/dist/docx/selectiveXmlPatch.js +222 -115
- package/dist/docx/serializer/groupTextBoxWriteBack.d.ts +12 -0
- package/dist/docx/serializer/groupTextBoxWriteBack.js +101 -0
- package/dist/docx/serializer/paragraphSerializer.js +5 -4
- package/dist/docx/serializer/runSerializer.d.ts +5 -1
- package/dist/docx/serializer/runSerializer.js +4 -3
- package/dist/docx/shapeAlternateContent.d.ts +25 -0
- package/dist/docx/shapeAlternateContent.js +52 -0
- package/dist/docx/textBoxParser.d.ts +10 -2
- package/dist/docx/textBoxParser.js +9 -3
- package/dist/internal/headlessRevisionResolution.js +50 -13
- package/dist/layout-bridge/convert/toFlowBlocks.js +26 -4
- package/dist/layout-engine/index.d.ts +2 -2
- package/dist/layout-engine/index.js +33 -4
- package/dist/layout-engine/keep-together.d.ts +5 -2
- package/dist/layout-engine/keep-together.js +5 -1
- package/dist/layout-engine/measure/advanceComposition.js +49 -3
- package/dist/layout-engine/measure/measureBlocks.js +5 -3
- package/dist/layout-engine/measure/measureContainer.js +46 -3
- package/dist/layout-engine/measure/measureHelpers.js +13 -3
- package/dist/layout-engine/measure/measureParagraph.js +44 -81
- package/dist/layout-engine/measure/smallCapsCasing.d.ts +56 -0
- package/dist/layout-engine/measure/smallCapsCasing.js +88 -0
- package/dist/layout-engine/paginator.d.ts +2 -0
- package/dist/layout-engine/paginator.js +5 -1
- package/dist/layout-engine/paragraphSpacing.d.ts +4 -2
- package/dist/layout-engine/paragraphSpacing.js +2 -1
- package/dist/layout-engine/tableRowBreak.d.ts +8 -1
- package/dist/layout-engine/tableRowBreak.js +22 -1
- package/dist/layout-engine/types.d.ts +21 -2
- package/dist/layout-painter/renderParagraph.js +41 -4
- package/dist/layout-painter/renderTextBox.d.ts +12 -2
- package/dist/layout-painter/renderTextBox.js +26 -2
- package/dist/prosemirror/alternateContentAttrs.d.ts +9 -0
- package/dist/prosemirror/alternateContentAttrs.js +35 -0
- package/dist/prosemirror/attrs/index.js +90 -0
- package/dist/prosemirror/commands/comments.js +51 -41
- package/dist/prosemirror/contentControlRevisions.d.ts +43 -0
- package/dist/prosemirror/contentControlRevisions.js +84 -0
- package/dist/prosemirror/conversion/fromProseDoc.js +46 -22
- package/dist/prosemirror/conversion/toProseDoc.js +42 -19
- package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.d.ts +7 -7
- package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -7
- package/dist/prosemirror/extensions/nodes/FieldExtension.js +6 -3
- package/dist/prosemirror/extensions/nodes/SdtExtension.js +9 -2
- package/dist/prosemirror/extensions/nodes/ShapeExtension.js +1 -0
- package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +3 -0
- package/dist/prosemirror/listMarker.js +2 -2
- package/dist/prosemirror/paragraphDirection.d.ts +29 -2
- package/dist/prosemirror/paragraphDirection.js +21 -2
- package/dist/prosemirror/plugins/suggestionMode.js +37 -8
- package/dist/prosemirror/rejoinRunCarriers.d.ts +27 -0
- package/dist/prosemirror/rejoinRunCarriers.js +66 -0
- package/dist/prosemirror/runIdentityAcrossRevisions.d.ts +10 -0
- package/dist/prosemirror/runIdentityAcrossRevisions.js +90 -0
- package/dist/prosemirror/schema/nodes.d.ts +37 -5
- package/dist/types/content.d.ts +2 -2
- package/package.json +2 -2
- package/dist/layout-engine/justifiedLineFit.d.ts +0 -7
- 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
|
-
|
|
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="${
|
|
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="${
|
|
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
|
-
|
|
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:
|
|
147
|
-
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
89
|
-
*
|
|
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[];
|