@stll/folio-core 0.27.0 → 0.28.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/table-cell-mutations.js +38 -3
- package/dist/docx/paragraphParser.js +3 -2
- package/dist/docx/parser.js +28 -5
- package/dist/docx/rezip.d.ts +21 -1
- package/dist/docx/rezip.js +80 -2
- package/dist/docx/runParser.js +6 -5
- package/dist/docx/selectiveSave.d.ts +0 -8
- package/dist/docx/selectiveSave.js +16 -1
- package/dist/docx/serializer/runSerializer.js +4 -4
- package/dist/docx/serializer/sectionPropertiesSerializer.js +5 -5
- package/dist/docx/server/createBilingualDocument.js +73 -7
- package/dist/docx/server/extractDocxText.js +124 -30
- package/dist/docx/server/materializeYjsDocx.d.ts +31 -0
- package/dist/docx/server/materializeYjsDocx.js +61 -0
- package/dist/docx/styleParser.js +6 -5
- package/dist/docx/unzip.js +17 -3
- package/dist/docx/xmlParser.js +34 -8
- package/dist/layout-painter/renderParagraph.js +36 -9
- package/dist/prosemirror/conversion/fromProseDoc.js +4 -3
- package/dist/server.d.ts +2 -1
- package/dist/server.js +2 -1
- package/dist/utils/headerFooter.js +0 -12
- package/dist/utils/tiffConverter.d.ts +11 -2
- package/dist/utils/tiffConverter.js +12 -4
- package/package.json +2 -2
|
@@ -14,6 +14,7 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
|
|
|
14
14
|
const tableStart = tablePosition + 1;
|
|
15
15
|
const seen = /* @__PURE__ */ new Set();
|
|
16
16
|
const cells = [];
|
|
17
|
+
const topRowCells = [];
|
|
17
18
|
let appendedContent = Fragment.empty;
|
|
18
19
|
for (let row = rectangle.top; row < rectangle.bottom; row++) for (let column = rectangle.left; column < rectangle.right; column++) {
|
|
19
20
|
const cellPosition = map.map[row * map.width + column];
|
|
@@ -25,6 +26,7 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
|
|
|
25
26
|
position: cellPosition,
|
|
26
27
|
cell
|
|
27
28
|
});
|
|
29
|
+
if (row === rectangle.top) topRowCells.push(cell);
|
|
28
30
|
if (cells.length > 1 && !isEmptyTableCell(cell)) appendedContent = appendedContent.append(cell.content);
|
|
29
31
|
}
|
|
30
32
|
const merged = cells.at(0);
|
|
@@ -34,8 +36,8 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
|
|
|
34
36
|
const colwidth = merged.cell.attrs["colwidth"];
|
|
35
37
|
if (typeof colspan !== "number" || !Number.isInteger(colspan) || colspan < 1 || typeof rowspan !== "number" || !Number.isInteger(rowspan) || rowspan < 1 || colwidth !== null && colwidth !== void 0 && (!Array.isArray(colwidth) || colwidth.length !== colspan || !colwidth.every((width) => typeof width === "number"))) return null;
|
|
36
38
|
const mergedColspan = rectangle.right - rectangle.left;
|
|
37
|
-
const nextColwidth =
|
|
38
|
-
|
|
39
|
+
const nextColwidth = mergeTopRowColwidths(topRowCells, mergedColspan);
|
|
40
|
+
const preferredWidth = mergeTopRowPreferredWidths(topRowCells);
|
|
39
41
|
const mapFrom = tr.mapping.maps.length;
|
|
40
42
|
for (const { position: cellPosition, cell } of cells.slice(1)) {
|
|
41
43
|
const position = tr.mapping.slice(mapFrom).map(tableStart + cellPosition);
|
|
@@ -46,7 +48,9 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
|
|
|
46
48
|
...merged.cell.attrs,
|
|
47
49
|
colspan: mergedColspan,
|
|
48
50
|
rowspan: rectangle.bottom - rectangle.top,
|
|
49
|
-
colwidth: nextColwidth
|
|
51
|
+
colwidth: nextColwidth,
|
|
52
|
+
width: preferredWidth.type === "value" ? preferredWidth.width : null,
|
|
53
|
+
widthType: preferredWidth.type === "value" ? preferredWidth.widthType : null
|
|
50
54
|
});
|
|
51
55
|
if (appendedContent.size > 0) {
|
|
52
56
|
const contentEnd = absoluteMergedPosition + 1 + merged.cell.content.size;
|
|
@@ -55,6 +59,37 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
|
|
|
55
59
|
}
|
|
56
60
|
return tr;
|
|
57
61
|
};
|
|
62
|
+
const mergeTopRowColwidths = (cells, mergedColspan) => {
|
|
63
|
+
const widths = [];
|
|
64
|
+
for (const cell of cells) {
|
|
65
|
+
const colspan = cell.attrs["colspan"];
|
|
66
|
+
const colwidth = cell.attrs["colwidth"];
|
|
67
|
+
if (typeof colspan !== "number" || !Number.isInteger(colspan) || colspan < 1 || !Array.isArray(colwidth) || colwidth.length !== colspan || !colwidth.every((width) => typeof width === "number" && width > 0)) return null;
|
|
68
|
+
widths.push(...colwidth);
|
|
69
|
+
}
|
|
70
|
+
return widths.length === mergedColspan ? widths : null;
|
|
71
|
+
};
|
|
72
|
+
/** A horizontal merge owns the whole top-row span. Sum compatible explicit
|
|
73
|
+
* preferred widths; retaining only the first cell's width makes a 50%+50%
|
|
74
|
+
* bilingual row serialize as a 50%-wide spanning cell. Mixed/implicit units
|
|
75
|
+
* cannot be composed safely, so clear the preference and let the table grid
|
|
76
|
+
* define the merged width. */
|
|
77
|
+
const mergeTopRowPreferredWidths = (cells) => {
|
|
78
|
+
let width = 0;
|
|
79
|
+
let widthType;
|
|
80
|
+
for (const cell of cells) {
|
|
81
|
+
const candidateWidth = cell.attrs["width"];
|
|
82
|
+
const candidateType = cell.attrs["widthType"];
|
|
83
|
+
if (typeof candidateWidth !== "number" || !Number.isFinite(candidateWidth) || candidateType !== "dxa" && candidateType !== "pct" || widthType !== void 0 && candidateType !== widthType) return { type: "absent" };
|
|
84
|
+
width += candidateWidth;
|
|
85
|
+
widthType = candidateType;
|
|
86
|
+
}
|
|
87
|
+
return widthType === void 0 ? { type: "absent" } : {
|
|
88
|
+
type: "value",
|
|
89
|
+
width,
|
|
90
|
+
widthType
|
|
91
|
+
};
|
|
92
|
+
};
|
|
58
93
|
const mergeTrackedVerticalTableCells = ({ tr, tablePosition, table, rectangle, revisionId, author, date }) => {
|
|
59
94
|
const map = TableMap.get(table);
|
|
60
95
|
if (rectangle.right - rectangle.left !== 1 || rectangle.bottom - rectangle.top < 2 || rectangle.left < 0 || rectangle.top < 0 || rectangle.right > map.width || rectangle.bottom > map.height || tableRectangleCutsMergedCell(map, rectangle)) return null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidHexColor } from "../utils/colorResolver.js";
|
|
1
2
|
import { isValidHexId } from "../utils/hexId.js";
|
|
2
3
|
import { parseBookmarkEnd as parseBookmarkEnd$1, parseBookmarkStart as parseBookmarkStart$1 } from "./bookmarkParser.js";
|
|
3
4
|
import { parseFieldType } from "./fieldParser.js";
|
|
@@ -48,9 +49,9 @@ function parseShadingProperties(shd) {
|
|
|
48
49
|
if (!shd) return;
|
|
49
50
|
const props = {};
|
|
50
51
|
const color = getAttribute(shd, "w", "color");
|
|
51
|
-
if (color && color !== "auto") props.color = { rgb: color };
|
|
52
|
+
if (color && color !== "auto" && isValidHexColor(color)) props.color = { rgb: color };
|
|
52
53
|
const fill = getAttribute(shd, "w", "fill");
|
|
53
|
-
if (fill && fill !== "auto") props.fill = { rgb: fill };
|
|
54
|
+
if (fill && fill !== "auto" && isValidHexColor(fill)) props.fill = { rgb: fill };
|
|
54
55
|
const validatedThemeFill = narrowEnum(getAttribute(shd, "w", "themeFill"), ThemeColorSlotSchema);
|
|
55
56
|
if (validatedThemeFill) {
|
|
56
57
|
props.fill = props.fill || {};
|
package/dist/docx/parser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { toArrayBuffer } from "../utils/docxInput.js";
|
|
2
2
|
import { loadFontsWithMapping } from "../utils/fontLoader.js";
|
|
3
|
-
import { convertTiffToPngDataUrl, isTiffMimeType } from "../utils/tiffConverter.js";
|
|
3
|
+
import { MAX_PACKAGE_TIFF_PIXELS, convertTiffToPngDataUrl, isTiffMimeType } from "../utils/tiffConverter.js";
|
|
4
4
|
import { parseComments } from "./commentParser.js";
|
|
5
5
|
import { normalizeCommentReferences } from "./commentReferenceNormalization.js";
|
|
6
6
|
import { detectDocxConformanceClass } from "./conformance.js";
|
|
@@ -237,14 +237,37 @@ function copyBytesToArrayBuffer(bytes) {
|
|
|
237
237
|
new Uint8Array(buffer).set(bytes);
|
|
238
238
|
return buffer;
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Media paths reachable from the package relationship graph: the document's own
|
|
242
|
+
* relationships plus every `.rels` part the unzip kept. Entries outside it are
|
|
243
|
+
* still stored so a round-trip keeps their bytes, but nothing decodes them.
|
|
244
|
+
*/
|
|
245
|
+
function collectReferencedMediaPaths(raw, rels) {
|
|
246
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
247
|
+
const addTargets = (map, relsPath) => {
|
|
248
|
+
for (const relationship of map.values()) {
|
|
249
|
+
if (!relationship.target || relationship.targetMode === "External") continue;
|
|
250
|
+
const partPath = resolveRelativePath(relsPath, relationship.target);
|
|
251
|
+
referenced.add(partPath.toLowerCase());
|
|
252
|
+
referenced.add(partPath.replace(/^word\//u, "").toLowerCase());
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
addTargets(rels, DOCUMENT_RELATIONSHIPS_PATH);
|
|
256
|
+
for (const [path, xml] of raw.allXml.entries()) if (path.toLowerCase().endsWith(".rels")) addTargets(parseRelationships(xml), path);
|
|
257
|
+
return referenced;
|
|
258
|
+
}
|
|
259
|
+
async function buildMediaMap(raw, rels) {
|
|
241
260
|
const media = /* @__PURE__ */ new Map();
|
|
261
|
+
const referenced = collectReferencedMediaPaths(raw, rels);
|
|
262
|
+
let remainingTiffPixels = MAX_PACKAGE_TIFF_PIXELS;
|
|
242
263
|
for (const [path, data] of raw.media.entries()) {
|
|
243
264
|
const filename = path.split("/").pop() || path;
|
|
244
265
|
const mimeType = getMediaMimeType(path);
|
|
245
|
-
|
|
246
|
-
|
|
266
|
+
const isReferenced = referenced.has(path.toLowerCase());
|
|
267
|
+
if (isReferenced && isTiffMimeType(mimeType) && remainingTiffPixels > 0) {
|
|
268
|
+
const converted = await convertTiffToPngDataUrl(data, remainingTiffPixels);
|
|
247
269
|
if (converted) {
|
|
270
|
+
remainingTiffPixels -= converted.pixels;
|
|
248
271
|
const mediaFile = {
|
|
249
272
|
path,
|
|
250
273
|
filename: filename.replace(/\.tiff?$/iu, ".png"),
|
|
@@ -258,7 +281,7 @@ async function buildMediaMap(raw, _rels) {
|
|
|
258
281
|
continue;
|
|
259
282
|
}
|
|
260
283
|
}
|
|
261
|
-
const raster = isMetafileMimeType(mimeType) ? extractMetafileRaster(data) : null;
|
|
284
|
+
const raster = isReferenced && isMetafileMimeType(mimeType) ? extractMetafileRaster(data) : null;
|
|
262
285
|
if (raster) {
|
|
263
286
|
const mediaFile = {
|
|
264
287
|
path,
|
package/dist/docx/rezip.d.ts
CHANGED
|
@@ -54,6 +54,26 @@ declare function addCommentsExtendedOverride(contentTypesXml: string): string;
|
|
|
54
54
|
declare function removeCommentsExtendedOverride(contentTypesXml: string): string;
|
|
55
55
|
declare function addCommentsExtendedRelationship(relsXml: string): string;
|
|
56
56
|
declare function removeCommentsExtendedRelationship(relsXml: string): string;
|
|
57
|
+
/** Result of filtering the settings part; `undefined` means "leave as it is". */
|
|
58
|
+
type SettingsWithoutAttachedTemplate = {
|
|
59
|
+
settingsXml: string | undefined;
|
|
60
|
+
relsXml: string | undefined;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Drop `w:attachedTemplate` from a `word/settings.xml` payload together with the
|
|
64
|
+
* relationships it resolves through.
|
|
65
|
+
*
|
|
66
|
+
* The elements are located in the parsed tree by namespace URI plus local name,
|
|
67
|
+
* so both the Transitional and the Strict WordprocessingML namespace are
|
|
68
|
+
* covered and a same-named element from a foreign namespace is left alone. Only
|
|
69
|
+
* the relationship ids those elements reference are removed from the `.rels`
|
|
70
|
+
* part: the settings part may also carry mail-merge and transform
|
|
71
|
+
* relationships, and their `r:id` values must keep resolving.
|
|
72
|
+
*
|
|
73
|
+
* The removal itself is a byte splice, so everything else in both parts
|
|
74
|
+
* round-trips exactly as authored.
|
|
75
|
+
*/
|
|
76
|
+
declare function withoutAttachedTemplate(settingsXml: string, relsXml: string | undefined): SettingsWithoutAttachedTemplate;
|
|
57
77
|
/**
|
|
58
78
|
* Update only document.xml in a DOCX buffer (minimal changes)
|
|
59
79
|
*
|
|
@@ -173,4 +193,4 @@ declare function createEmptyDocx(): Promise<ArrayBuffer>;
|
|
|
173
193
|
*/
|
|
174
194
|
declare function createDocx(doc: document_d_exports.Document): Promise<ArrayBuffer>;
|
|
175
195
|
//#endregion
|
|
176
|
-
export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, RepackOptions, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, collectHyperlinksWithoutRId, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, notePartRelsPath, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx };
|
|
196
|
+
export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, RepackOptions, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, collectHyperlinksWithoutRId, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, notePartRelsPath, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx, withoutAttachedTemplate };
|
package/dist/docx/rezip.js
CHANGED
|
@@ -18,7 +18,7 @@ import { serializeStyle, serializeStylesXml } from "./serializer/stylesSerialize
|
|
|
18
18
|
import { serializeThemeXml } from "./serializer/themeSerializer.js";
|
|
19
19
|
import { escapeXml } from "./serializer/xmlUtils.js";
|
|
20
20
|
import { isPreservableDocxEntry } from "./unzip.js";
|
|
21
|
-
import { WORDPROCESSINGML_NAMESPACE_URIS, findChild, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml } from "./xmlParser.js";
|
|
21
|
+
import { WORDPROCESSINGML_NAMESPACE_URIS, findChild, getAttribute, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml, parseXmlDocument } from "./xmlParser.js";
|
|
22
22
|
import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
|
|
23
23
|
import { panic } from "better-result";
|
|
24
24
|
import { validateDocxPackage } from "@stll/docx-core";
|
|
@@ -459,6 +459,7 @@ const finishRepack = async ({ document, originalZip, outputZip, originalDocument
|
|
|
459
459
|
await serializeNumberingIntoZip(document, originalZip, outputZip, compressionLevel);
|
|
460
460
|
await serializeAddedStylesIntoZip(document, originalZip, outputZip, compressionLevel);
|
|
461
461
|
await serializeCommentsToZip(document, outputZip, compressionLevel);
|
|
462
|
+
await dropAttachedTemplateReference(outputZip, compressionLevel);
|
|
462
463
|
if (updateModifiedDate && originalCorePropertiesXml) {
|
|
463
464
|
const updatedCoreProperties = updateCoreProperties(originalCorePropertiesXml, {
|
|
464
465
|
updateModifiedDate,
|
|
@@ -547,6 +548,7 @@ async function repackDocxFromRaw(doc, rawContent, options = {}) {
|
|
|
547
548
|
await serializeNumberingIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
|
|
548
549
|
await serializeAddedStylesIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
|
|
549
550
|
await serializeCommentsToZip(exportDocument, newZip, compressionLevel);
|
|
551
|
+
await dropAttachedTemplateReference(newZip, compressionLevel);
|
|
550
552
|
if (updateModifiedDate && rawContent.corePropsXml) {
|
|
551
553
|
const updatedCoreProps = updateCoreProperties(rawContent.corePropsXml, {
|
|
552
554
|
updateModifiedDate,
|
|
@@ -582,6 +584,82 @@ function addCommentsExtendedRelationship(relsXml) {
|
|
|
582
584
|
function removeCommentsExtendedRelationship(relsXml) {
|
|
583
585
|
return relsXml.replace(/<Relationship\b[^>]*commentsExtended\.xml[^>]*\/>/giu, "");
|
|
584
586
|
}
|
|
587
|
+
const SETTINGS_PART = "word/settings.xml";
|
|
588
|
+
const SETTINGS_RELS_PART = "word/_rels/settings.xml.rels";
|
|
589
|
+
const ATTACHED_TEMPLATE_LOCAL_NAME = "attachedTemplate";
|
|
590
|
+
const ATTACHED_TEMPLATE_ELEMENT = /<(?<prefix>[\w.-]+:)?attachedTemplate\b[^>]*?(?:\/>|>\s*<\/(?:[\w.-]+:)?attachedTemplate>)/giu;
|
|
591
|
+
const RELATIONSHIP_ELEMENT = /<Relationship\b[^>]*?(?:\/>|>\s*<\/Relationship>)/giu;
|
|
592
|
+
const RELATIONSHIP_ID_ATTRIBUTE = /\bId\s*=\s*(?<quote>["'])(?<value>[^"']*)\k<quote>/u;
|
|
593
|
+
/**
|
|
594
|
+
* Drop `w:attachedTemplate` from a `word/settings.xml` payload together with the
|
|
595
|
+
* relationships it resolves through.
|
|
596
|
+
*
|
|
597
|
+
* The elements are located in the parsed tree by namespace URI plus local name,
|
|
598
|
+
* so both the Transitional and the Strict WordprocessingML namespace are
|
|
599
|
+
* covered and a same-named element from a foreign namespace is left alone. Only
|
|
600
|
+
* the relationship ids those elements reference are removed from the `.rels`
|
|
601
|
+
* part: the settings part may also carry mail-merge and transform
|
|
602
|
+
* relationships, and their `r:id` values must keep resolving.
|
|
603
|
+
*
|
|
604
|
+
* The removal itself is a byte splice, so everything else in both parts
|
|
605
|
+
* round-trips exactly as authored.
|
|
606
|
+
*/
|
|
607
|
+
function withoutAttachedTemplate(settingsXml, relsXml) {
|
|
608
|
+
const root = parseXmlDocument(settingsXml);
|
|
609
|
+
if (!root) return {
|
|
610
|
+
settingsXml: void 0,
|
|
611
|
+
relsXml: void 0
|
|
612
|
+
};
|
|
613
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
614
|
+
const referencedRIds = /* @__PURE__ */ new Set();
|
|
615
|
+
for (const element of getChildElements(root)) {
|
|
616
|
+
if (getLocalName(element.name) !== ATTACHED_TEMPLATE_LOCAL_NAME || !WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "")) continue;
|
|
617
|
+
const name = element.name ?? "";
|
|
618
|
+
const separatorIndex = name.indexOf(":");
|
|
619
|
+
prefixes.add(separatorIndex === -1 ? "" : name.slice(0, separatorIndex));
|
|
620
|
+
const rId = getAttribute(element, "r", "id");
|
|
621
|
+
if (rId) referencedRIds.add(rId);
|
|
622
|
+
}
|
|
623
|
+
if (prefixes.size === 0) return {
|
|
624
|
+
settingsXml: void 0,
|
|
625
|
+
relsXml: void 0
|
|
626
|
+
};
|
|
627
|
+
const filteredSettings = settingsXml.replace(ATTACHED_TEMPLATE_ELEMENT, (match, prefix) => prefixes.has(prefix?.slice(0, -1) ?? "") ? "" : match);
|
|
628
|
+
return {
|
|
629
|
+
settingsXml: filteredSettings === settingsXml ? void 0 : filteredSettings,
|
|
630
|
+
relsXml: relsXml === void 0 ? void 0 : withoutRelationships(relsXml, referencedRIds)
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/** Drop the named relationships from a `.rels` payload; `undefined` when unchanged. */
|
|
634
|
+
function withoutRelationships(relsXml, ids) {
|
|
635
|
+
if (ids.size === 0) return;
|
|
636
|
+
const filtered = relsXml.replace(RELATIONSHIP_ELEMENT, (relationship) => {
|
|
637
|
+
const id = RELATIONSHIP_ID_ATTRIBUTE.exec(relationship)?.groups?.["value"];
|
|
638
|
+
return id !== void 0 && ids.has(id) ? "" : relationship;
|
|
639
|
+
});
|
|
640
|
+
return filtered === relsXml ? void 0 : filtered;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* The settings part and its relationships are otherwise copied from the source
|
|
644
|
+
* package byte for byte. `w:attachedTemplate` resolves through a relationship
|
|
645
|
+
* whose target sits outside the package (`TargetMode="External"`); the document
|
|
646
|
+
* model has no field for it, so a preserved copy would carry a reference folio
|
|
647
|
+
* can neither read nor rewrite. Both sides are filtered on save.
|
|
648
|
+
*/
|
|
649
|
+
async function dropAttachedTemplateReference(zip, compressionLevel) {
|
|
650
|
+
const settingsFile = zip.file(SETTINGS_PART);
|
|
651
|
+
if (!settingsFile) return;
|
|
652
|
+
const relsFile = zip.file(SETTINGS_RELS_PART);
|
|
653
|
+
const filtered = withoutAttachedTemplate(await settingsFile.async("text"), await relsFile?.async("text"));
|
|
654
|
+
if (filtered.settingsXml !== void 0) zip.file(SETTINGS_PART, filtered.settingsXml, {
|
|
655
|
+
compression: "DEFLATE",
|
|
656
|
+
compressionOptions: { level: compressionLevel }
|
|
657
|
+
});
|
|
658
|
+
if (filtered.relsXml !== void 0) zip.file(SETTINGS_RELS_PART, filtered.relsXml, {
|
|
659
|
+
compression: "DEFLATE",
|
|
660
|
+
compressionOptions: { level: compressionLevel }
|
|
661
|
+
});
|
|
662
|
+
}
|
|
585
663
|
/**
|
|
586
664
|
* Ensure [Content_Types].xml contains an Override for word/comments.xml.
|
|
587
665
|
* If the document already had comments, this is a no-op.
|
|
@@ -1413,4 +1491,4 @@ const assertStyleNumberingReferences = (doc) => {
|
|
|
1413
1491
|
for (const numbering of doc.package.numbering?.nums ?? []) if (!availableAbstract.has(numbering.abstractNumId)) panic(`Numbering definition ${numbering.numId} references missing abstract numbering`);
|
|
1414
1492
|
};
|
|
1415
1493
|
//#endregion
|
|
1416
|
-
export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, collectHyperlinksWithoutRId, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, notePartRelsPath, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx };
|
|
1494
|
+
export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, collectHyperlinksWithoutRId, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, notePartRelsPath, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx, withoutAttachedTemplate };
|
package/dist/docx/runParser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidHexColor } from "../utils/colorResolver.js";
|
|
1
2
|
import { parseGroupDrawing } from "./groupDrawingParser.js";
|
|
2
3
|
import { parseImage } from "./imageParser.js";
|
|
3
4
|
import { EmphasisMarkSchema, FontHintSchema, FontThemeSchema, HighlightColorSchema, PositionalTabAlignmentSchema, PositionalTabLeaderSchema, PositionalTabRelativeToSchema, ShadingPatternSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
|
|
@@ -37,9 +38,9 @@ function parseShadingProperties(shd) {
|
|
|
37
38
|
if (!shd) return;
|
|
38
39
|
const props = {};
|
|
39
40
|
const color = getAttribute(shd, "w", "color");
|
|
40
|
-
if (color && color !== "auto") props.color = { rgb: color };
|
|
41
|
+
if (color && color !== "auto" && isValidHexColor(color)) props.color = { rgb: color };
|
|
41
42
|
const fill = getAttribute(shd, "w", "fill");
|
|
42
|
-
if (fill && fill !== "auto") props.fill = { rgb: fill };
|
|
43
|
+
if (fill && fill !== "auto" && isValidHexColor(fill)) props.fill = { rgb: fill };
|
|
43
44
|
const validatedThemeFill = narrowEnum(getAttribute(shd, "w", "themeFill"), ThemeColorSlotSchema);
|
|
44
45
|
if (validatedThemeFill) {
|
|
45
46
|
if (!props.fill) props.fill = {};
|
|
@@ -252,7 +253,7 @@ function parseRunProperties(rPr, theme, _styles) {
|
|
|
252
253
|
if (resolved) fontFamily.ascii = resolved;
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
|
-
const hAnsiTheme = getAttribute(rFonts, "w", "hAnsiTheme");
|
|
256
|
+
const hAnsiTheme = narrowEnum(getAttribute(rFonts, "w", "hAnsiTheme"), FontThemeSchema);
|
|
256
257
|
if (hAnsiTheme) {
|
|
257
258
|
fontFamily.hAnsiTheme = hAnsiTheme;
|
|
258
259
|
if (theme && !fontFamily.hAnsi) {
|
|
@@ -260,7 +261,7 @@ function parseRunProperties(rPr, theme, _styles) {
|
|
|
260
261
|
if (resolved) fontFamily.hAnsi = resolved;
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
|
-
const eastAsiaTheme = getAttribute(rFonts, "w", "eastAsiaTheme");
|
|
264
|
+
const eastAsiaTheme = narrowEnum(getAttribute(rFonts, "w", "eastAsiaTheme"), FontThemeSchema);
|
|
264
265
|
if (eastAsiaTheme) {
|
|
265
266
|
fontFamily.eastAsiaTheme = eastAsiaTheme;
|
|
266
267
|
if (theme && !fontFamily.eastAsia) {
|
|
@@ -268,7 +269,7 @@ function parseRunProperties(rPr, theme, _styles) {
|
|
|
268
269
|
if (resolved) fontFamily.eastAsia = resolved;
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
|
-
const csTheme = getAttribute(rFonts, "w", "cstheme");
|
|
272
|
+
const csTheme = narrowEnum(getAttribute(rFonts, "w", "cstheme"), FontThemeSchema);
|
|
272
273
|
if (csTheme) {
|
|
273
274
|
fontFamily.csTheme = csTheme;
|
|
274
275
|
if (theme && !fontFamily.cs) {
|
|
@@ -14,14 +14,6 @@ type SelectiveSaveOptions = {
|
|
|
14
14
|
*/
|
|
15
15
|
maxBytes?: number;
|
|
16
16
|
};
|
|
17
|
-
/**
|
|
18
|
-
* Attempt a selective save — patch only changed paragraphs in document.xml.
|
|
19
|
-
* Also updates comments, headers/footers, and core properties so that
|
|
20
|
-
* all document parts stay in sync even when only paragraphs are patched.
|
|
21
|
-
*
|
|
22
|
-
* Returns the saved ArrayBuffer, or null if selective save is not possible
|
|
23
|
-
* (caller should fall back to full repack).
|
|
24
|
-
*/
|
|
25
17
|
declare function attemptSelectiveSave(doc: document_d_exports.Document, originalBuffer: ArrayBuffer, options: SelectiveSaveOptions): Promise<ArrayBuffer | null>;
|
|
26
18
|
//#endregion
|
|
27
19
|
export { SelectiveSaveOptions, attemptSelectiveSave };
|
|
@@ -5,7 +5,7 @@ import { validateFolioDocumentModel } from "./modelValidation.js";
|
|
|
5
5
|
import { isNewDataUrlDrawing } from "./newImage.js";
|
|
6
6
|
import { parseNumbering } from "./numberingParser.js";
|
|
7
7
|
import { RELATIONSHIP_TYPES } from "./relsParser.js";
|
|
8
|
-
import { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_PART_LOWER, addCommentsExtendedOverride, addCommentsExtendedRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, updateCoreProperties } from "./rezip.js";
|
|
8
|
+
import { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_PART_LOWER, addCommentsExtendedOverride, addCommentsExtendedRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, updateCoreProperties, withoutAttachedTemplate } from "./rezip.js";
|
|
9
9
|
import "./selectiveSaveFlags.js";
|
|
10
10
|
import { buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
|
|
11
11
|
import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
|
|
@@ -174,6 +174,20 @@ async function patchNumberingPart(zip, doc, updates) {
|
|
|
174
174
|
* Returns the saved ArrayBuffer, or null if selective save is not possible
|
|
175
175
|
* (caller should fall back to full repack).
|
|
176
176
|
*/
|
|
177
|
+
/**
|
|
178
|
+
* Queue the filtered `word/settings.xml` and `word/_rels/settings.xml.rels`
|
|
179
|
+
* when the source package carries an attached-template reference. Shares
|
|
180
|
+
* `withoutAttachedTemplate` with the full-repack path so both saves emit the
|
|
181
|
+
* same package for the same source.
|
|
182
|
+
*/
|
|
183
|
+
const queueSettingsUpdates = async (zip, updates) => {
|
|
184
|
+
const settingsFile = zip.file("word/settings.xml");
|
|
185
|
+
if (!settingsFile) return;
|
|
186
|
+
const relsFile = zip.file("word/_rels/settings.xml.rels");
|
|
187
|
+
const filtered = withoutAttachedTemplate(await settingsFile.async("text"), await relsFile?.async("text"));
|
|
188
|
+
if (filtered.settingsXml !== void 0) updates.set("word/settings.xml", filtered.settingsXml);
|
|
189
|
+
if (filtered.relsXml !== void 0) updates.set("word/_rels/settings.xml.rels", filtered.relsXml);
|
|
190
|
+
};
|
|
177
191
|
async function attemptSelectiveSave(doc, originalBuffer, options) {
|
|
178
192
|
const { changedParaIds, structuralChange, hasUntrackedChanges } = options;
|
|
179
193
|
const maxBytes = options.maxBytes ?? 104857600;
|
|
@@ -243,6 +257,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
|
|
|
243
257
|
if (!await patchCommentsExtended(zip, comments, updates)) return null;
|
|
244
258
|
await patchNumberingPart(zip, doc, updates);
|
|
245
259
|
for (const [path, xml] of headerFooterUpdates) updates.set(path, xml);
|
|
260
|
+
await queueSettingsUpdates(zip, updates);
|
|
246
261
|
const corePropsFile = zip.file("docProps/core.xml");
|
|
247
262
|
if (corePropsFile) {
|
|
248
263
|
const corePropsXml = await corePropsFile.async("text");
|
|
@@ -85,10 +85,10 @@ function serializeTextFormatting(formatting) {
|
|
|
85
85
|
if (formatting.fontFamily.eastAsia) fontAttrs.push(`w:eastAsia="${escapeXml(formatting.fontFamily.eastAsia)}"`);
|
|
86
86
|
if (formatting.fontFamily.cs) fontAttrs.push(`w:cs="${escapeXml(formatting.fontFamily.cs)}"`);
|
|
87
87
|
if (formatting.fontFamily.hint) fontAttrs.push(`w:hint="${escapeXml(formatting.fontFamily.hint)}"`);
|
|
88
|
-
if (formatting.fontFamily.asciiTheme) fontAttrs.push(`w:asciiTheme="${formatting.fontFamily.asciiTheme}"`);
|
|
89
|
-
if (formatting.fontFamily.hAnsiTheme) fontAttrs.push(`w:hAnsiTheme="${formatting.fontFamily.hAnsiTheme}"`);
|
|
90
|
-
if (formatting.fontFamily.eastAsiaTheme) fontAttrs.push(`w:eastAsiaTheme="${formatting.fontFamily.eastAsiaTheme}"`);
|
|
91
|
-
if (formatting.fontFamily.csTheme) fontAttrs.push(`w:cstheme="${formatting.fontFamily.csTheme}"`);
|
|
88
|
+
if (formatting.fontFamily.asciiTheme) fontAttrs.push(`w:asciiTheme="${escapeXml(formatting.fontFamily.asciiTheme)}"`);
|
|
89
|
+
if (formatting.fontFamily.hAnsiTheme) fontAttrs.push(`w:hAnsiTheme="${escapeXml(formatting.fontFamily.hAnsiTheme)}"`);
|
|
90
|
+
if (formatting.fontFamily.eastAsiaTheme) fontAttrs.push(`w:eastAsiaTheme="${escapeXml(formatting.fontFamily.eastAsiaTheme)}"`);
|
|
91
|
+
if (formatting.fontFamily.csTheme) fontAttrs.push(`w:cstheme="${escapeXml(formatting.fontFamily.csTheme)}"`);
|
|
92
92
|
if (fontAttrs.length > 0) parts.push(`<w:rFonts ${fontAttrs.join(" ")}/>`);
|
|
93
93
|
}
|
|
94
94
|
if (formatting.language) {
|
|
@@ -83,7 +83,7 @@ function serializePageNumbering(props) {
|
|
|
83
83
|
if (pageNumbering.format) attrs.push(`w:fmt="${pageNumbering.format}"`);
|
|
84
84
|
if (pageNumbering.start !== void 0) attrs.push(`w:start="${intAttr(pageNumbering.start)}"`);
|
|
85
85
|
if (pageNumbering.chapterStyle !== void 0) attrs.push(`w:chapStyle="${intAttr(pageNumbering.chapterStyle)}"`);
|
|
86
|
-
if (pageNumbering.chapterSeparator) attrs.push(`w:chapSep="${pageNumbering.chapterSeparator}"`);
|
|
86
|
+
if (pageNumbering.chapterSeparator) attrs.push(`w:chapSep="${escapeXml(pageNumbering.chapterSeparator)}"`);
|
|
87
87
|
return attrs.length > 0 ? `<w:pgNumType ${attrs.join(" ")}/>` : "";
|
|
88
88
|
}
|
|
89
89
|
function serializePageBorders(props) {
|
|
@@ -113,10 +113,10 @@ function serializeBackground(props) {
|
|
|
113
113
|
const attrs = [];
|
|
114
114
|
const { background } = props;
|
|
115
115
|
if (background.color?.auto) attrs.push("w:color=\"auto\"");
|
|
116
|
-
else if (background.color?.rgb) attrs.push(`w:color="${background.color.rgb}"`);
|
|
117
|
-
if (background.themeColor ?? background.color?.themeColor) attrs.push(`w:themeColor="${background.themeColor ?? background.color?.themeColor}"`);
|
|
118
|
-
if (background.themeTint ?? background.color?.themeTint) attrs.push(`w:themeTint="${background.themeTint ?? background.color?.themeTint}"`);
|
|
119
|
-
if (background.themeShade ?? background.color?.themeShade) attrs.push(`w:themeShade="${background.themeShade ?? background.color?.themeShade}"`);
|
|
116
|
+
else if (background.color?.rgb) attrs.push(`w:color="${escapeXml(background.color.rgb)}"`);
|
|
117
|
+
if (background.themeColor ?? background.color?.themeColor) attrs.push(`w:themeColor="${escapeXml(background.themeColor ?? background.color?.themeColor ?? "")}"`);
|
|
118
|
+
if (background.themeTint ?? background.color?.themeTint) attrs.push(`w:themeTint="${escapeXml(background.themeTint ?? background.color?.themeTint ?? "")}"`);
|
|
119
|
+
if (background.themeShade ?? background.color?.themeShade) attrs.push(`w:themeShade="${escapeXml(background.themeShade ?? background.color?.themeShade ?? "")}"`);
|
|
120
120
|
return attrs.length > 0 ? `<w:background ${attrs.join(" ")}/>` : "";
|
|
121
121
|
}
|
|
122
122
|
function serializeDocGrid(props) {
|
|
@@ -10,7 +10,10 @@ import { TaggedError } from "better-result";
|
|
|
10
10
|
* cloned per language, so both columns count independently (1. / 1. instead of
|
|
11
11
|
* 1. / 2.) and stay live in Word. Right-column paragraphs receive fresh
|
|
12
12
|
* `paraId`s so callers can address each row later (for example to replace the
|
|
13
|
-
* placeholder copy with a translation by block id).
|
|
13
|
+
* placeholder copy with a translation by block id). Horizontal paragraph
|
|
14
|
+
* geometry is projected into the half-width cells: full-page indents and tab
|
|
15
|
+
* stops otherwise place signature fields outside their column and let prose
|
|
16
|
+
* overlap the translation.
|
|
14
17
|
*
|
|
15
18
|
* Section breaks cannot live inside a table cell, so the body is split at
|
|
16
19
|
* paragraphs carrying `sectionProperties`: each section becomes its own table
|
|
@@ -28,6 +31,8 @@ const HALF_WIDTH_PCT = 2500;
|
|
|
28
31
|
const A4_TEXT_WIDTH_TWIPS = 9072;
|
|
29
32
|
const ROW_ID_NAMESPACE = "folio-bilingual";
|
|
30
33
|
const BILINGUAL_TABLE_STYLE_ID = "FolioBilingualTranslation";
|
|
34
|
+
const MIN_COLUMN_TEXT_WIDTH_TWIPS = 720;
|
|
35
|
+
const MIN_TAB_TRAILING_WIDTH_TWIPS = 360;
|
|
31
36
|
const GRID_BORDER = {
|
|
32
37
|
style: "single",
|
|
33
38
|
size: 4,
|
|
@@ -111,7 +116,7 @@ function createBilingualDocument(source, options) {
|
|
|
111
116
|
rowId: ref.targetParaId,
|
|
112
117
|
...ref
|
|
113
118
|
});
|
|
114
|
-
sectionRows.push(buildRow(block, copy));
|
|
119
|
+
sectionRows.push(buildRow(block, copy, styleById, textWidth));
|
|
115
120
|
continue;
|
|
116
121
|
}
|
|
117
122
|
const paragraphs = collectTableParagraphs(block).filter((paragraph) => paragraph.paraId !== void 0 && options.editableParagraphIds.has(paragraph.paraId)).map((paragraph) => ({
|
|
@@ -362,11 +367,72 @@ const collectTableParagraphs = (table) => {
|
|
|
362
367
|
else out.push(...collectTableParagraphs(item));
|
|
363
368
|
return out;
|
|
364
369
|
};
|
|
365
|
-
const buildRow = (left, right) =>
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
+
const buildRow = (left, right, styleById, textWidth) => {
|
|
371
|
+
const columnWidth = Math.floor(textWidth / 2);
|
|
372
|
+
const geometry = resolveHorizontalParagraphGeometry(left, styleById);
|
|
373
|
+
return {
|
|
374
|
+
type: "tableRow",
|
|
375
|
+
formatting: { cantSplit: true },
|
|
376
|
+
cells: [buildCell(projectParagraphIntoColumn(left, geometry, textWidth, columnWidth)), buildCell(projectParagraphIntoColumn(right, geometry, textWidth, columnWidth))]
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
const HORIZONTAL_PARAGRAPH_KEYS = [
|
|
380
|
+
"indentLeft",
|
|
381
|
+
"indentRight",
|
|
382
|
+
"indentFirstLine",
|
|
383
|
+
"hangingIndent",
|
|
384
|
+
"tabs"
|
|
385
|
+
];
|
|
386
|
+
/** Resolve only the paragraph properties whose coordinates change when a
|
|
387
|
+
* full-width paragraph is placed in a half-width cell. Direct pPr wins over
|
|
388
|
+
* the basedOn style chain, matching Word's paragraph-style cascade. */
|
|
389
|
+
const resolveHorizontalParagraphGeometry = (paragraph, styleById) => {
|
|
390
|
+
const chain = [];
|
|
391
|
+
const seen = /* @__PURE__ */ new Set();
|
|
392
|
+
let style = paragraph.formatting?.styleId ? styleById.get(paragraph.formatting.styleId) : void 0;
|
|
393
|
+
while (style && !seen.has(style.styleId)) {
|
|
394
|
+
seen.add(style.styleId);
|
|
395
|
+
chain.push(style);
|
|
396
|
+
style = style.basedOn ? styleById.get(style.basedOn) : void 0;
|
|
397
|
+
}
|
|
398
|
+
const geometry = {};
|
|
399
|
+
for (const current of chain.toReversed()) assignHorizontalParagraphGeometry(geometry, current.pPr);
|
|
400
|
+
assignHorizontalParagraphGeometry(geometry, paragraph.formatting);
|
|
401
|
+
return geometry;
|
|
402
|
+
};
|
|
403
|
+
const assignHorizontalParagraphGeometry = (target, source) => {
|
|
404
|
+
for (const key of HORIZONTAL_PARAGRAPH_KEYS) {
|
|
405
|
+
const value = source?.[key];
|
|
406
|
+
if (value !== void 0) Object.assign(target, { [key]: value });
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
const projectParagraphIntoColumn = (paragraph, geometry, sourceWidth, columnWidth) => {
|
|
410
|
+
const scale = columnWidth / sourceWidth;
|
|
411
|
+
const maxSideIndent = Math.max(0, columnWidth - MIN_COLUMN_TEXT_WIDTH_TWIPS);
|
|
412
|
+
let indentLeft = projectSideIndent(geometry.indentLeft, scale, maxSideIndent);
|
|
413
|
+
let indentRight = projectSideIndent(geometry.indentRight, scale, maxSideIndent);
|
|
414
|
+
if (indentLeft + indentRight - maxSideIndent > 0) {
|
|
415
|
+
const total = indentLeft + indentRight;
|
|
416
|
+
indentLeft = Math.round(indentLeft / total * maxSideIndent);
|
|
417
|
+
indentRight = maxSideIndent - indentLeft;
|
|
418
|
+
}
|
|
419
|
+
const formatting = {
|
|
420
|
+
...paragraph.formatting,
|
|
421
|
+
...geometry.indentLeft !== void 0 && { indentLeft },
|
|
422
|
+
...geometry.indentRight !== void 0 && { indentRight },
|
|
423
|
+
...geometry.indentFirstLine !== void 0 && { indentFirstLine: Math.round(geometry.indentFirstLine * scale) },
|
|
424
|
+
...geometry.hangingIndent !== void 0 && { hangingIndent: geometry.hangingIndent },
|
|
425
|
+
...geometry.tabs !== void 0 && { tabs: geometry.tabs.map((tab) => ({
|
|
426
|
+
...tab,
|
|
427
|
+
position: Math.min(Math.max(0, Math.round(tab.position * scale)), Math.max(0, columnWidth - MIN_TAB_TRAILING_WIDTH_TWIPS))
|
|
428
|
+
})) }
|
|
429
|
+
};
|
|
430
|
+
return {
|
|
431
|
+
...paragraph,
|
|
432
|
+
formatting
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
const projectSideIndent = (value, scale, maximum) => Math.min(Math.max(0, Math.round((value ?? 0) * scale)), maximum);
|
|
370
436
|
const buildCell = (paragraph) => ({
|
|
371
437
|
type: "tableCell",
|
|
372
438
|
formatting: {
|