@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha
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/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// checkFit (FR4a, ADR-0042): whether each filled value stays on the lines its
|
|
2
|
+
// box gives it — the width a text box gives its text, the width a filled line
|
|
3
|
+
// takes, and the decision between them. Integer arithmetic throughout (EMU,
|
|
4
|
+
// 1 pt = 12700, 1 twip = 635, and u = EMU × 2048 for sums of advances);
|
|
5
|
+
// points appear only in the report. No break point is ever chosen
|
|
6
|
+
// (invariant #9): a line is measured whole.
|
|
7
|
+
//
|
|
8
|
+
// A box is measured only in the shape the evidence covers (ADR-0042 Karar 3):
|
|
9
|
+
// one rectangular wps:wsp of its own, unrotated, unframed, its extent the
|
|
10
|
+
// same in wp:extent and a:ext, the body wrapping in one horizontal column.
|
|
11
|
+
// Anything else is a reason — "unknown" — never a guess.
|
|
12
|
+
|
|
13
|
+
import { scanXml, findChild, getAttr } from "./xml-scan.js";
|
|
14
|
+
import { W_NS, WP_NS, A_NS, WPS_NS, WPG_NS, WP14_NS, WORD_COMPAT_URI, XML_NS } from "./ns.js";
|
|
15
|
+
import { paragraphStyleOf, layoutResolver } from "./run-fonts.js";
|
|
16
|
+
import { readTemplatePackage, prepareFill, tagInfo, ENGINE_VERSION } from "./fill.js";
|
|
17
|
+
import { CALIBRI_METRICS } from "../../fonts/calibri-metrics.js";
|
|
18
|
+
import { devFreeze } from "../../dev.js";
|
|
19
|
+
|
|
20
|
+
/** DrawingML's default left and right inset (ECMA-376 §21.1.2.1.1 bodyPr: 91440 EMU, 0.1 in). */
|
|
21
|
+
export const DEFAULT_INSET_EMU = 91440;
|
|
22
|
+
const NON_NEGATIVE = /^[0-9]+$/;
|
|
23
|
+
const ON = new Set(["1", "true", "on"]);
|
|
24
|
+
|
|
25
|
+
// Widths are summed in u = EMU × 2048, so a Calibri advance at a size in half-points is an
|
|
26
|
+
// integer: adv/2048 em × hps/2 pt × 12700 EMU/pt × 2048 = adv × hps × 6350.
|
|
27
|
+
/** u per EMU */
|
|
28
|
+
export const U_PER_EMU = 2048;
|
|
29
|
+
const U_PER_TWIP = 635 * U_PER_EMU;
|
|
30
|
+
const U_PER_ADVANCE_HALF_POINT = (6350 * 2048) / CALIBRI_METRICS.unitsPerEm;
|
|
31
|
+
// Spaces at a line's end: whether Word lets them hang past the edge is not known — FR4-WORD-FIT2 laid out no
|
|
32
|
+
// line that ended in one, U+0020 included (ADR-0042 H3) — so a line is decided both with them and without.
|
|
33
|
+
const SPACE_AT_END = (/** @type {number} */ cp) => cp === 0x20 || cp === 0xa0 || (cp >= 0x2000 && cp <= 0x200a) || cp === 0x202f || cp === 0x205f || cp === 0x3000;
|
|
34
|
+
const EAST_ASIAN_LANGUAGE = /^(zh|ja|ko)(-|$)/i;
|
|
35
|
+
// Paragraph and run children checkFit reads (text, tabs, breaks) or that draw nothing; any other one is a reason.
|
|
36
|
+
const PARAGRAPH_MARKERS = new Set(["pPr", "r", "bookmarkStart", "bookmarkEnd", "proofErr", "commentRangeStart", "commentRangeEnd", "permStart", "permEnd"]);
|
|
37
|
+
const RUN_MARKERS = new Set(["rPr", "t", "tab", "br", "lastRenderedPageBreak"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {import("./xml-scan.js").XmlElement} XmlElement
|
|
41
|
+
* @typedef {import("./run-fonts.js").Unknown} Unknown
|
|
42
|
+
* @typedef {{widthEmu: number, lInsEmu: number, rInsEmu: number, textWidthEmu: number}} BoxGeometry
|
|
43
|
+
* `textWidthEmu`: the width between the insets, where a paragraph's indents start
|
|
44
|
+
* @typedef {object} LineMeasure one line of a filled paragraph
|
|
45
|
+
* @property {number} startEmu where it starts, from the box's inner left edge: the indents
|
|
46
|
+
* @property {number} limitEmu where it must end: the text width less the right indent
|
|
47
|
+
* @property {number} widthU its natural width, every character counted — trailing spaces too (u = EMU × 2048)
|
|
48
|
+
* @property {number} widthWithoutTrailingU the same with the trailing spaces left out, as if Word hung them (H3)
|
|
49
|
+
* @property {number} glyphs characters in `widthU` since its last exact place — the line start or a tab stop — whose
|
|
50
|
+
* deviations τ covers
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/** @type {Map<string, Map<number, number>> | null} */
|
|
54
|
+
let advances = null;
|
|
55
|
+
/**
|
|
56
|
+
* The metrics table as a code point -> advance map per style, built on first use.
|
|
57
|
+
* @param {"regular" | "bold" | "italic" | "boldItalic"} style
|
|
58
|
+
* @returns {Map<number, number>}
|
|
59
|
+
*/
|
|
60
|
+
function advancesOf(style) {
|
|
61
|
+
if (advances === null) {
|
|
62
|
+
advances = new Map();
|
|
63
|
+
for (const [name, runs] of Object.entries(CALIBRI_METRICS.styles)) {
|
|
64
|
+
/** @type {Map<number, number>} */
|
|
65
|
+
const map = new Map();
|
|
66
|
+
for (const [first, list] of runs) list.forEach((adv, i) => map.set(first + i, adv));
|
|
67
|
+
advances.set(name, map);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return /** @type {Map<number, number>} */ (advances.get(style));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {XmlElement} el
|
|
75
|
+
* @returns {XmlElement[]}
|
|
76
|
+
*/
|
|
77
|
+
const elementChildren = (el) => /** @type {XmlElement[]} */ (el.children.filter((c) => c.kind === "element"));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The width a scanned text box gives its text, or why it cannot be known.
|
|
81
|
+
* @param {import("./slots.js").AlternateContentRecord} ac a box the slot scan accepted
|
|
82
|
+
* @returns {{box: BoxGeometry} | {unknown: Unknown}}
|
|
83
|
+
*/
|
|
84
|
+
export function boxGeometry(ac) {
|
|
85
|
+
/** @param {string} code @param {string} [property] @returns {{unknown: Unknown}} */
|
|
86
|
+
const no = (code, property) => ({ unknown: property === undefined ? { code } : { code, property } });
|
|
87
|
+
const anchor = ac.choiceAnchors[0];
|
|
88
|
+
// Word writes wp14:sizeRelH with pctWidth 0 on boxes of absolute size; any other width is a share of the page.
|
|
89
|
+
const sizeRelH = findChild(anchor, WP14_NS, "sizeRelH");
|
|
90
|
+
const pctWidth = sizeRelH && findChild(sizeRelH, WP14_NS, "pctWidth");
|
|
91
|
+
const pct = pctWidth ? pctWidth.children.map((c) => (c.kind === "text" ? c.value : "")).join("").trim() : undefined;
|
|
92
|
+
if (sizeRelH && pct !== "0") return no("BOX_RELATIVE_SIZE");
|
|
93
|
+
const extent = findChild(anchor, WP_NS, "extent");
|
|
94
|
+
const cx = extent && getAttr(extent, "", "cx");
|
|
95
|
+
if (cx === undefined || !NON_NEGATIVE.test(cx)) return no("BOX_EXTENT_UNREADABLE");
|
|
96
|
+
|
|
97
|
+
const graphic = findChild(anchor, A_NS, "graphic");
|
|
98
|
+
const data = graphic && findChild(graphic, A_NS, "graphicData");
|
|
99
|
+
if (data && findChild(data, WPG_NS, "wgp")) return no("BOX_GROUP");
|
|
100
|
+
const wsp = data && findChild(data, WPS_NS, "wsp");
|
|
101
|
+
const txbx = wsp && findChild(wsp, WPS_NS, "txbx");
|
|
102
|
+
if (!wsp || !txbx || findChild(txbx, W_NS, "txbxContent") !== ac.choiceTx[0]) return no("BOX_SHAPE");
|
|
103
|
+
// A text box Word links into a chain carries an id; its overflow continues in the next box.
|
|
104
|
+
if (getAttr(txbx, "", "id") !== undefined) return no("BOX_LINKED");
|
|
105
|
+
|
|
106
|
+
const spPr = findChild(wsp, WPS_NS, "spPr");
|
|
107
|
+
const xfrm = spPr && findChild(spPr, A_NS, "xfrm");
|
|
108
|
+
if (xfrm) {
|
|
109
|
+
const rot = getAttr(xfrm, "", "rot");
|
|
110
|
+
if (rot !== undefined && rot !== "0") return no("BOX_ROTATED", "rot");
|
|
111
|
+
for (const flip of ["flipH", "flipV"]) {
|
|
112
|
+
const v = getAttr(xfrm, "", flip);
|
|
113
|
+
if (v !== undefined && ON.has(v)) return no("BOX_ROTATED", flip);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const ext = xfrm && findChild(xfrm, A_NS, "ext");
|
|
117
|
+
if (!ext || getAttr(ext, "", "cx") !== cx) return no("BOX_EXTENT_MISMATCH");
|
|
118
|
+
const geometry = spPr && findChild(spPr, A_NS, "prstGeom");
|
|
119
|
+
if (!geometry || getAttr(geometry, "", "prst") !== "rect") return no("BOX_GEOMETRY");
|
|
120
|
+
const ln = spPr && findChild(spPr, A_NS, "ln");
|
|
121
|
+
if (ln) {
|
|
122
|
+
if (!findChild(ln, A_NS, "noFill")) return no("BOX_OUTLINE");
|
|
123
|
+
} else {
|
|
124
|
+
// No line of its own: the shape style's line reference draws one unless it is 0.
|
|
125
|
+
const style = findChild(wsp, WPS_NS, "style");
|
|
126
|
+
const lnRef = style && findChild(style, A_NS, "lnRef");
|
|
127
|
+
if (lnRef && getAttr(lnRef, "", "idx") !== "0") return no("BOX_OUTLINE");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const bodyPr = findChild(wsp, WPS_NS, "bodyPr");
|
|
131
|
+
let lIns = DEFAULT_INSET_EMU;
|
|
132
|
+
let rIns = DEFAULT_INSET_EMU;
|
|
133
|
+
if (bodyPr) {
|
|
134
|
+
/** @type {[string, (v: string) => boolean][]} */
|
|
135
|
+
const rules = [
|
|
136
|
+
["wrap", (v) => v === "square"],
|
|
137
|
+
["vert", (v) => v === "horz"],
|
|
138
|
+
["numCol", (v) => v === "1"],
|
|
139
|
+
["rot", (v) => v === "0"],
|
|
140
|
+
["fromWordArt", (v) => !ON.has(v)],
|
|
141
|
+
];
|
|
142
|
+
for (const [name, ok] of rules) {
|
|
143
|
+
const v = getAttr(bodyPr, "", name);
|
|
144
|
+
if (v !== undefined && !ok(v)) return no("BOX_BODY", name);
|
|
145
|
+
}
|
|
146
|
+
for (const c of elementChildren(bodyPr)) {
|
|
147
|
+
if (c.uri === A_NS && c.localName === "normAutofit") return no("BOX_AUTOFIT");
|
|
148
|
+
if (!(c.uri === A_NS && (c.localName === "noAutofit" || c.localName === "spAutoFit"))) return no("BOX_BODY", c.localName);
|
|
149
|
+
}
|
|
150
|
+
const l = getAttr(bodyPr, "", "lIns");
|
|
151
|
+
const r = getAttr(bodyPr, "", "rIns");
|
|
152
|
+
if ((l !== undefined && !NON_NEGATIVE.test(l)) || (r !== undefined && !NON_NEGATIVE.test(r))) return no("BOX_INSET_UNREADABLE");
|
|
153
|
+
if (l !== undefined) lIns = Number(l);
|
|
154
|
+
if (r !== undefined) rIns = Number(r);
|
|
155
|
+
}
|
|
156
|
+
const widthEmu = Number(cx);
|
|
157
|
+
const textWidthEmu = widthEmu - lIns - rIns;
|
|
158
|
+
if (textWidthEmu <= 0) return no("BOX_NO_ROOM");
|
|
159
|
+
return { box: { widthEmu, lInsEmu: lIns, rInsEmu: rIns, textWidthEmu } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @typedef {{cp: number, u: number, sp: number, tab?: true}} Item a character, or a tab's jump to its stop
|
|
164
|
+
* @typedef {{lines: LineMeasure[], stops: number[]}} Laid the lines, and the stop each tab reached (u)
|
|
165
|
+
* @typedef {{unknown: Unknown & {codePoint?: number}} | {overflow: {code: "TAB_STOP_PASSED"}}} NotLaid
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The lines a paragraph holds once its tags are filled, each measured: where it
|
|
170
|
+
* starts, where it must end, how wide it is (ADR-0042 Karar 5). A line ends at a
|
|
171
|
+
* w:br — the template's, or one a multi-line value's LF becomes. No break point is
|
|
172
|
+
* ever chosen (invariant #9): a line is measured whole, as the template and the
|
|
173
|
+
* values give it.
|
|
174
|
+
*
|
|
175
|
+
* A tab jumps to the first left stop past the pen, stops measured from the box's
|
|
176
|
+
* inner left edge (H6). The paragraph is laid out once with its tags empty: that
|
|
177
|
+
* fixes the stop each tab reaches. Filled, a tab whose text has passed that stop
|
|
178
|
+
* has broken the column the template aligned — an overflow, TAB_STOP_PASSED, even
|
|
179
|
+
* if the line itself still fits.
|
|
180
|
+
* @param {object} input
|
|
181
|
+
* @param {XmlElement} input.paragraph
|
|
182
|
+
* @param {BoxGeometry} input.box
|
|
183
|
+
* @param {ReturnType<typeof import("./run-fonts.js").layoutResolver>} input.layout
|
|
184
|
+
* @param {Map<XmlElement, string>} input.values tag run -> the value written into it
|
|
185
|
+
* @param {boolean} [input.preserve] every w:t inherits xml:space="preserve" (inheritsPreserve);
|
|
186
|
+
* otherwise a template text with edge whitespace and no preserve of its own is unknown —
|
|
187
|
+
* Word may drop that whitespace
|
|
188
|
+
* @returns {{lines: LineMeasure[]} | NotLaid}
|
|
189
|
+
*/
|
|
190
|
+
export function measureParagraph({ paragraph, box, layout, values, preserve = false }) {
|
|
191
|
+
const styleId = paragraphStyleOf(paragraph);
|
|
192
|
+
const p = layout.paragraph(styleId, findChild(paragraph, W_NS, "pPr"));
|
|
193
|
+
if (p.unknown.length > 0) return { unknown: p.unknown[0] };
|
|
194
|
+
if (p.leftTwips < 0 || p.rightTwips < 0 || p.leftTwips + p.firstLineTwips < 0) return { unknown: { code: "INDENT_OUTSIDE" } };
|
|
195
|
+
const limitEmu = box.textWidthEmu - p.rightTwips * 635;
|
|
196
|
+
const limitU = limitEmu * U_PER_EMU;
|
|
197
|
+
/** @param {number} line @returns {number} EMU */
|
|
198
|
+
const startOf = (line) => (p.leftTwips + (line === 0 ? p.firstLineTwips : 0)) * 635;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {Map<XmlElement, string>} vals
|
|
202
|
+
* @param {number[] | null} expected the stops the blank layout's tabs reached; null for that layout itself
|
|
203
|
+
* @returns {Laid | NotLaid}
|
|
204
|
+
*/
|
|
205
|
+
const layOut = (vals, expected) => {
|
|
206
|
+
/** @type {Item[][]} */
|
|
207
|
+
const lines = [[]];
|
|
208
|
+
/** @type {number[]} */
|
|
209
|
+
const stops = [];
|
|
210
|
+
let x = startOf(0) * U_PER_EMU;
|
|
211
|
+
let sinceExact = 0;
|
|
212
|
+
for (const child of paragraph.children) {
|
|
213
|
+
if (child.kind !== "element") continue;
|
|
214
|
+
if (child.uri !== W_NS || !PARAGRAPH_MARKERS.has(child.localName)) return { unknown: { code: "PARAGRAPH_CONTENT", property: child.localName } };
|
|
215
|
+
if (child.localName !== "r") continue;
|
|
216
|
+
const run = layout.run(styleId, findChild(child, W_NS, "rPr"));
|
|
217
|
+
if (run.unknown.length > 0) return { unknown: run.unknown[0] };
|
|
218
|
+
const hps = run.sizeHalfPoints;
|
|
219
|
+
if (hps === null) return { unknown: { code: "SIZE_UNKNOWN" } };
|
|
220
|
+
const style = run.bold ? (run.italic ? "boldItalic" : "bold") : run.italic ? "italic" : "regular";
|
|
221
|
+
const table = advancesOf(style);
|
|
222
|
+
const value = vals.get(child);
|
|
223
|
+
/** @param {string} text @returns {Unknown & {codePoint?: number} | null} */
|
|
224
|
+
const add = (text) => {
|
|
225
|
+
for (const ch of text) {
|
|
226
|
+
const cp = /** @type {number} */ (ch.codePointAt(0));
|
|
227
|
+
const font = cp <= 0x7f ? run.font.ascii : run.font.hAnsi;
|
|
228
|
+
if (font === null || font.toLowerCase() !== CALIBRI_METRICS.family.toLowerCase()) return { code: "FONT_NOT_SUPPORTED" };
|
|
229
|
+
if (cp > 0x7f && run.hint !== null && run.hint !== "default") return { code: "FONT_HINT" };
|
|
230
|
+
if (cp > 0x7f && run.eastAsiaLanguage !== null && EAST_ASIAN_LANGUAGE.test(run.eastAsiaLanguage)) return { code: "EAST_ASIAN_LANGUAGE" };
|
|
231
|
+
const adv = table.get(cp);
|
|
232
|
+
if (adv === undefined) return { code: "UNKNOWN_CHARACTER", codePoint: cp };
|
|
233
|
+
// Letter spacing follows every character, the last included (ADR-0042 H5).
|
|
234
|
+
const sp = run.spacingTwips * U_PER_TWIP;
|
|
235
|
+
const u = adv * hps * U_PER_ADVANCE_HALF_POINT + sp;
|
|
236
|
+
lines[lines.length - 1].push({ cp, u, sp });
|
|
237
|
+
x += u;
|
|
238
|
+
sinceExact += 1;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
};
|
|
242
|
+
const newLine = () => {
|
|
243
|
+
lines.push([]);
|
|
244
|
+
x = startOf(lines.length - 1) * U_PER_EMU;
|
|
245
|
+
sinceExact = 0;
|
|
246
|
+
};
|
|
247
|
+
/** @returns {Unknown | {overflow: {code: "TAB_STOP_PASSED"}} | null} */
|
|
248
|
+
const tab = () => {
|
|
249
|
+
// A hanging first line gets an implicit stop at the left indent: not modelled.
|
|
250
|
+
if (p.firstLineTwips < 0) return { code: "TAB_HANGING" };
|
|
251
|
+
// Measured in Word only on left-aligned lines in boxes without a left inset (FR4-WORD-FIT2):
|
|
252
|
+
// a centred or right line moves its stops, and an inset leaves "the inner edge" untested (H6).
|
|
253
|
+
if (p.jc !== "left" && p.jc !== "start") return { code: "TAB_ALIGNMENT" };
|
|
254
|
+
if (box.lInsEmu !== 0) return { code: "TAB_INSET" };
|
|
255
|
+
// The pen's place is exact at a line start or a stop; τ grows with the glyphs since.
|
|
256
|
+
const tau = TAU_BASE_U + TAU_PER_GLYPH_U * sinceExact;
|
|
257
|
+
let stop;
|
|
258
|
+
if (expected !== null) {
|
|
259
|
+
stop = expected[stops.length];
|
|
260
|
+
if (x > stop + tau) return { overflow: { code: "TAB_STOP_PASSED" } };
|
|
261
|
+
if (x >= stop - tau) return { code: "TAB_BORDERLINE" };
|
|
262
|
+
} else {
|
|
263
|
+
const at = p.tabs.map((t) => ({ ...t, u: t.posTwips * 635 * U_PER_EMU }));
|
|
264
|
+
const next = at.find((t) => t.u > x);
|
|
265
|
+
const previous = at.filter((t) => t.u <= x).pop();
|
|
266
|
+
if (next === undefined) return { code: "TAB_DEFAULT" };
|
|
267
|
+
if (!next.left) return { code: "TAB_STOP_KIND" };
|
|
268
|
+
if (next.u - x <= tau || (previous !== undefined && x - previous.u <= tau)) return { code: "TAB_BORDERLINE" };
|
|
269
|
+
if (next.u > limitU) return { code: "TAB_BEYOND" };
|
|
270
|
+
stop = next.u;
|
|
271
|
+
}
|
|
272
|
+
lines[lines.length - 1].push({ cp: 0x09, u: stop - x, sp: 0, tab: true });
|
|
273
|
+
stops.push(stop);
|
|
274
|
+
x = stop;
|
|
275
|
+
sinceExact = 0;
|
|
276
|
+
return null;
|
|
277
|
+
};
|
|
278
|
+
if (value !== undefined) {
|
|
279
|
+
// A tag run holds its one w:t and nothing else that draws (the slot scan's grammar); the value
|
|
280
|
+
// replaces it, a multi-line value's LFs becoming w:br inside the run (ADR-0039).
|
|
281
|
+
for (const [i, line] of value.split("\n").entries()) {
|
|
282
|
+
if (i > 0) newLine();
|
|
283
|
+
const bad = add(line);
|
|
284
|
+
if (bad !== null) return { unknown: bad };
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const c of child.children) {
|
|
289
|
+
if (c.kind !== "element") continue;
|
|
290
|
+
if (c.uri !== W_NS || !RUN_MARKERS.has(c.localName)) return { unknown: { code: "RUN_CONTENT", property: c.localName } };
|
|
291
|
+
if (c.localName === "t") {
|
|
292
|
+
const text = c.children.map((y) => (y.kind === "text" || y.kind === "cdata" ? y.value : "")).join("");
|
|
293
|
+
if (!preserve && getAttr(c, XML_NS, "space") !== "preserve" && /^\s|\s$/.test(text)) return { unknown: { code: "WHITESPACE_UNPRESERVED" } };
|
|
294
|
+
const bad = add(text);
|
|
295
|
+
if (bad !== null) return { unknown: bad };
|
|
296
|
+
} else if (c.localName === "tab") {
|
|
297
|
+
const bad = tab();
|
|
298
|
+
if (bad !== null) return "overflow" in bad ? bad : { unknown: bad };
|
|
299
|
+
} else if (c.localName === "br") {
|
|
300
|
+
const type = getAttr(c, W_NS, "type");
|
|
301
|
+
if (type !== undefined && type !== "textWrapping") return { unknown: { code: "RUN_CONTENT", property: `br ${type}` } };
|
|
302
|
+
newLine();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
stops,
|
|
308
|
+
lines: lines.map((items, i) => {
|
|
309
|
+
let end = items.length;
|
|
310
|
+
while (end > 0 && SPACE_AT_END(items[end - 1].cp)) end -= 1;
|
|
311
|
+
const sum = (/** @type {number} */ n) => items.slice(0, n).reduce((u, c) => u + c.u, 0);
|
|
312
|
+
const lastTab = items.map((c) => c.tab === true).lastIndexOf(true);
|
|
313
|
+
return {
|
|
314
|
+
startEmu: startOf(i),
|
|
315
|
+
limitEmu,
|
|
316
|
+
widthU: sum(items.length),
|
|
317
|
+
widthWithoutTrailingU: sum(end),
|
|
318
|
+
glyphs: items.length - lastTab - 1,
|
|
319
|
+
};
|
|
320
|
+
}),
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const tabbed = paragraph.children.some((r) => r.kind === "element" && r.uri === W_NS && r.localName === "r" && !values.has(r) && findChild(r, W_NS, "tab") !== undefined);
|
|
325
|
+
if (!tabbed) {
|
|
326
|
+
const laid = layOut(values, null);
|
|
327
|
+
return "lines" in laid ? { lines: laid.lines } : laid;
|
|
328
|
+
}
|
|
329
|
+
const blank = layOut(new Map([...values.keys()].map((run) => [run, ""])), null);
|
|
330
|
+
if (!("lines" in blank)) return blank;
|
|
331
|
+
const laid = layOut(values, blank.stops);
|
|
332
|
+
return "lines" in laid ? { lines: laid.lines } : laid;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- the decision and the public function --------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
const STYLES_PART = "word/styles.xml";
|
|
338
|
+
const SETTINGS_PART = "word/settings.xml";
|
|
339
|
+
const THEME_PART = "word/theme/theme1.xml";
|
|
340
|
+
// w:compat children the FR4-WORD-FIT gate measures with (KR09 carries them); one turned off is no change.
|
|
341
|
+
const COMPAT_OPTIONS = new Set(["ulTrailSpace", "shapeLayoutLikeWW8", "useFELayout"]);
|
|
342
|
+
// Word 2010's compatibility settings next to mode 14; the others touch tables, mirrored indents or
|
|
343
|
+
// OpenType features a run asks for (w14, itself unknown).
|
|
344
|
+
const COMPAT_SETTINGS = new Set(["compatibilityMode", "overrideTableStyleFontSizeAndJustification", "enableOpenTypeFeatures", "doNotFlipMirrorIndents", "differentiateMultirowTableHeaders"]);
|
|
345
|
+
const OFF = new Set(["0", "false", "off"]);
|
|
346
|
+
/** τ = 0.05 pt + 0.025 pt a glyph (ADR-0042 Karar 6), in u. */
|
|
347
|
+
export const TAU_BASE_U = 635 * U_PER_EMU;
|
|
348
|
+
export const TAU_PER_GLYPH_U = 317.5 * U_PER_EMU;
|
|
349
|
+
/** @type {Record<FitStatus, number>} */
|
|
350
|
+
const SEVERITY = { empty: 0, fits: 1, unknown: 2, overflows: 3 };
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @typedef {"fits" | "overflows" | "unknown" | "empty"} FitStatus
|
|
354
|
+
* @typedef {{status: "fits" | "overflows"} | {status: "unknown", reason: Unknown}} LineVerdict
|
|
355
|
+
* @typedef {object} FitOccurrence
|
|
356
|
+
* @property {number | null} docPrId
|
|
357
|
+
* @property {number} page
|
|
358
|
+
* @property {FitStatus} status
|
|
359
|
+
* @property {string} [reason] from a closed list (ADR-0042)
|
|
360
|
+
* @property {string} [property] the property or element the reason names
|
|
361
|
+
* @property {number} [codePoint] the character the table lacks
|
|
362
|
+
* @property {number} [widthPt] the tightest line's width
|
|
363
|
+
* @property {number} [availablePt] the room it has
|
|
364
|
+
* @property {number} [marginPt] room less width; negative when it overflows
|
|
365
|
+
* @property {number} [lines] a multi-line value's line count
|
|
366
|
+
* @property {number} [capacity]
|
|
367
|
+
* @property {string[]} [paragraphTags] the other tags measured on the same line
|
|
368
|
+
* @typedef {{name: string, status: FitStatus, occurrences: FitOccurrence[]}} FitTag
|
|
369
|
+
* @typedef {object} FitReport
|
|
370
|
+
* @property {boolean} ok no occurrence overflows or is unknown — the caller's approval rule
|
|
371
|
+
* @property {FitTag[]} tags sorted by name
|
|
372
|
+
* @property {{tags: number, occurrences: number, fits: number, overflows: number, unknown: number, empty: number}} counts
|
|
373
|
+
* @property {{version: string, metrics: {family: string, carlito: string | null, calibri: string | null}}} engine
|
|
374
|
+
*/
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* One line against its room: fits when even with its trailing spaces counted and τ more it would
|
|
378
|
+
* fit, overflows when even with them hanging and τ less it would not; between, unknown (ADR-0042
|
|
379
|
+
* Karar 6) — TRAILING_SPACE when the trailing spaces alone decide.
|
|
380
|
+
* @param {LineMeasure} line
|
|
381
|
+
* @returns {LineVerdict}
|
|
382
|
+
*/
|
|
383
|
+
export function decideLine(line) {
|
|
384
|
+
const available = (line.limitEmu - line.startEmu) * U_PER_EMU;
|
|
385
|
+
const tau = TAU_BASE_U + TAU_PER_GLYPH_U * line.glyphs;
|
|
386
|
+
if (line.widthU <= available - tau) return { status: "fits" };
|
|
387
|
+
if (line.widthWithoutTrailingU > available + tau) return { status: "overflows" };
|
|
388
|
+
const trailing = line.widthWithoutTrailingU !== line.widthU && (line.widthWithoutTrailingU <= available - tau || line.widthU > available + tau);
|
|
389
|
+
return { status: "unknown", reason: { code: trailing ? "TRAILING_SPACE" : "BORDERLINE" } };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Whether every w:t of the document inherits xml:space="preserve": the root sets it and no element
|
|
394
|
+
* below sets anything else (Word writes the root's; all 46 corpus documents carry it).
|
|
395
|
+
* @param {XmlElement} root
|
|
396
|
+
* @returns {boolean}
|
|
397
|
+
*/
|
|
398
|
+
export function inheritsPreserve(root) {
|
|
399
|
+
if (getAttr(root, XML_NS, "space") !== "preserve") return false;
|
|
400
|
+
/** @param {XmlElement} el @returns {boolean} */
|
|
401
|
+
const overrides = (el) => elementChildren(el).some((c) => {
|
|
402
|
+
const v = getAttr(c, XML_NS, "space");
|
|
403
|
+
return (v !== undefined && v !== "preserve") || overrides(c);
|
|
404
|
+
});
|
|
405
|
+
return !overrides(root);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Settings that change every line, or that the evidence does not cover: the compatibility mode
|
|
410
|
+
* (14 only), the w:compat options, East Asian punctuation compression, a character grid.
|
|
411
|
+
* @param {Uint8Array | undefined} settingsXml
|
|
412
|
+
* @param {XmlElement} documentRoot
|
|
413
|
+
* @returns {Unknown | null}
|
|
414
|
+
*/
|
|
415
|
+
export function documentUnknown(settingsXml, documentRoot) {
|
|
416
|
+
if (settingsXml === undefined) return { code: "COMPAT_MODE" };
|
|
417
|
+
const settings = scanXml(settingsXml).root;
|
|
418
|
+
const control = findChild(settings, W_NS, "characterSpacingControl");
|
|
419
|
+
if (control && getAttr(control, W_NS, "val") !== "doNotCompress") return { code: "SETTING", property: "characterSpacingControl" };
|
|
420
|
+
const compat = findChild(settings, W_NS, "compat");
|
|
421
|
+
/** @type {string | undefined} */
|
|
422
|
+
let mode;
|
|
423
|
+
for (const c of compat ? elementChildren(compat) : []) {
|
|
424
|
+
if (c.uri === W_NS && c.localName === "compatSetting") {
|
|
425
|
+
const name = getAttr(c, W_NS, "name");
|
|
426
|
+
if (getAttr(c, W_NS, "uri") !== WORD_COMPAT_URI || name === undefined || !COMPAT_SETTINGS.has(name)) return { code: "COMPAT_OPTION", property: name ?? "compatSetting" };
|
|
427
|
+
if (name === "compatibilityMode") mode = getAttr(c, W_NS, "val");
|
|
428
|
+
} else if (!(c.uri === W_NS && COMPAT_OPTIONS.has(c.localName)) && !OFF.has(getAttr(c, W_NS, "val") ?? "")) {
|
|
429
|
+
return { code: "COMPAT_OPTION", property: c.localName };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (mode !== "14") return { code: "COMPAT_MODE" };
|
|
433
|
+
/** @param {XmlElement} el @returns {boolean} */
|
|
434
|
+
const charGrid = (el) =>
|
|
435
|
+
elementChildren(el).some((c) => {
|
|
436
|
+
if (c.uri === W_NS && c.localName === "docGrid") {
|
|
437
|
+
const type = getAttr(c, W_NS, "type");
|
|
438
|
+
return type !== undefined && type !== "default" && type !== "lines";
|
|
439
|
+
}
|
|
440
|
+
return charGrid(c);
|
|
441
|
+
});
|
|
442
|
+
return charGrid(documentRoot) ? { code: "DOCUMENT_GRID" } : null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* checkFit's own option: `capacity`, tag -> the lines a multi-line tag's box shows.
|
|
447
|
+
* @param {unknown} value
|
|
448
|
+
* @param {readonly string[]} multiline
|
|
449
|
+
* @returns {{capacity: Map<string, number>, problems: import("./fill.js").Problem[]}}
|
|
450
|
+
*/
|
|
451
|
+
function readCapacity(value, multiline) {
|
|
452
|
+
/** @type {Map<string, number>} */
|
|
453
|
+
const capacity = new Map();
|
|
454
|
+
if (value === undefined) return { capacity, problems: [] };
|
|
455
|
+
const proto = value !== null && typeof value === "object" ? Object.getPrototypeOf(value) : undefined;
|
|
456
|
+
const entries = proto === Object.prototype || proto === null ? Object.entries(/** @type {object} */ (value)) : null;
|
|
457
|
+
if (entries === null || !entries.every(([, n]) => Number.isSafeInteger(n) && /** @type {number} */ (n) > 0)) {
|
|
458
|
+
throw new TypeError("checkFit: options.capacity must be a plain object of positive integers (tag -> lines)");
|
|
459
|
+
}
|
|
460
|
+
/** @type {import("./fill.js").Problem[]} */
|
|
461
|
+
const problems = [];
|
|
462
|
+
for (const [name, n] of entries.sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
|
|
463
|
+
if (multiline.includes(name)) capacity.set(name, /** @type {number} */ (n));
|
|
464
|
+
else problems.push({ code: "CAPACITY_NOT_MULTILINE", message: "capacity names a tag the multiline option does not", tag: name });
|
|
465
|
+
}
|
|
466
|
+
return { capacity, problems };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** @param {number} u @returns {number} */
|
|
470
|
+
const ptOf = (u) => Math.round((u / U_PER_EMU / 12700) * 100) / 100;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Whether each value of `data` stays on the lines its box gives it, as Word lays the filled
|
|
474
|
+
* template out (ADR-0042). Validates exactly as fillTemplate does — the same TemplateError for
|
|
475
|
+
* the same input — and writes nothing. The report carries no value: widths, reasons, code points.
|
|
476
|
+
* @param {Uint8Array} bytes a .docx template
|
|
477
|
+
* @param {import("./fill.js").FillData} data
|
|
478
|
+
* @param {{expectedTags?: readonly string[], multiline?: readonly string[], capacity?: Record<string, number>}} [options]
|
|
479
|
+
* `capacity`: tag -> lines, for multi-line tags only; a binding manifest carries it as `fit`
|
|
480
|
+
* @returns {Promise<FitReport>}
|
|
481
|
+
*/
|
|
482
|
+
export async function checkFit(bytes, data, options) {
|
|
483
|
+
const pkg = await readTemplatePackage("checkFit", bytes, [STYLES_PART, SETTINGS_PART, THEME_PART]);
|
|
484
|
+
/** @type {Map<string, number>} */
|
|
485
|
+
let capacity = new Map();
|
|
486
|
+
const prepared = prepareFill("checkFit", pkg.root, pkg.xml, data, options, pkg.otherPartProblems, ["expectedTags", "multiline", "capacity"], (o) => {
|
|
487
|
+
const read = readCapacity(o.given.capacity, o.multiline ?? []);
|
|
488
|
+
capacity = read.capacity;
|
|
489
|
+
return read.problems;
|
|
490
|
+
});
|
|
491
|
+
const { scan, valueOf, multilineSet } = prepared;
|
|
492
|
+
const styles = pkg.parts.get(STYLES_PART);
|
|
493
|
+
const everywhere = styles === undefined ? { code: "STYLES_MISSING" } : documentUnknown(pkg.parts.get(SETTINGS_PART), pkg.root);
|
|
494
|
+
const layout = styles === undefined ? null : layoutResolver(styles, pkg.parts.get(THEME_PART) ?? null);
|
|
495
|
+
const preserve = inheritsPreserve(pkg.root);
|
|
496
|
+
|
|
497
|
+
/** @type {Map<XmlElement, string>} */
|
|
498
|
+
const values = new Map(scan.occurrences.map((o) => [o.choice.run, /** @type {string} */ (valueOf.get(o.name))]));
|
|
499
|
+
/** @type {Map<XmlElement, string[]>} */
|
|
500
|
+
const tagsOf = new Map();
|
|
501
|
+
for (const o of scan.occurrences) tagsOf.set(o.choice.paragraph, [...(tagsOf.get(o.choice.paragraph) ?? []), o.name]);
|
|
502
|
+
/** @type {Map<XmlElement, ReturnType<typeof measureParagraph>>} */
|
|
503
|
+
const measured = new Map();
|
|
504
|
+
|
|
505
|
+
/** @type {Map<string, FitOccurrence[]>} */
|
|
506
|
+
const byName = new Map();
|
|
507
|
+
for (const o of scan.occurrences) {
|
|
508
|
+
const paragraph = o.choice.paragraph;
|
|
509
|
+
const runs = paragraph.children.filter((c) => c.kind === "element" && c.uri === W_NS && c.localName === "r").length;
|
|
510
|
+
const others = [...new Set((tagsOf.get(paragraph) ?? []).filter((n) => n !== o.name))].sort();
|
|
511
|
+
/** @type {FitOccurrence} */
|
|
512
|
+
const out = { docPrId: o.docPrId, page: o.page, status: "unknown" };
|
|
513
|
+
/** @param {Unknown & {codePoint?: number}} u */
|
|
514
|
+
const unknown = (u) => {
|
|
515
|
+
out.status = "unknown";
|
|
516
|
+
out.reason = u.code;
|
|
517
|
+
if (u.property !== undefined) out.property = u.property;
|
|
518
|
+
if (u.codePoint !== undefined) out.codePoint = u.codePoint;
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* @param {string} name
|
|
522
|
+
* @param {LineMeasure[]} lines
|
|
523
|
+
* @param {boolean} lone the tag's run is the paragraph's only one
|
|
524
|
+
*/
|
|
525
|
+
const decide = (name, lines, lone) => {
|
|
526
|
+
const verdicts = lines.map(decideLine);
|
|
527
|
+
const roomOf = (/** @type {LineMeasure} */ l) => (l.limitEmu - l.startEmu) * U_PER_EMU;
|
|
528
|
+
const tightest = lines.reduce((best, l) => (roomOf(l) - l.widthU < roomOf(best) - best.widthU ? l : best));
|
|
529
|
+
out.widthPt = ptOf(tightest.widthU);
|
|
530
|
+
out.availablePt = ptOf(roomOf(tightest));
|
|
531
|
+
out.marginPt = ptOf(roomOf(tightest) - tightest.widthU);
|
|
532
|
+
const firstUnknown = verdicts.find((v) => v.status === "unknown");
|
|
533
|
+
if (!multilineSet.has(name)) {
|
|
534
|
+
if (verdicts.some((v) => v.status === "overflows")) out.status = "overflows";
|
|
535
|
+
else if (firstUnknown !== undefined && firstUnknown.status === "unknown") unknown(firstUnknown.reason);
|
|
536
|
+
else out.status = "fits";
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// A multi-line value: n lines the caller broke, m of them too wide — each of those takes at
|
|
540
|
+
// least one more line; how many more is the browser's to say (FR4b).
|
|
541
|
+
out.lines = lines.length;
|
|
542
|
+
if (!lone) return unknown({ code: "SHARED_MULTILINE" });
|
|
543
|
+
const c = capacity.get(name);
|
|
544
|
+
if (c === undefined) return unknown({ code: "NO_CAPACITY" });
|
|
545
|
+
out.capacity = c;
|
|
546
|
+
const m = verdicts.filter((v) => v.status === "overflows").length;
|
|
547
|
+
if (lines.length + m > c) out.status = "overflows";
|
|
548
|
+
else if (m === 0 && firstUnknown === undefined) out.status = "fits";
|
|
549
|
+
else if (m > 0) unknown({ code: "WRAPS" });
|
|
550
|
+
else if (firstUnknown !== undefined && firstUnknown.status === "unknown") unknown(firstUnknown.reason);
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const value = /** @type {string} */ (valueOf.get(o.name));
|
|
554
|
+
// Empty is nothing written: whitespace still takes width, and a multi-line value's LFs take lines.
|
|
555
|
+
if (value === "" && runs === 1) {
|
|
556
|
+
out.status = "empty";
|
|
557
|
+
} else if (everywhere !== null) {
|
|
558
|
+
unknown(everywhere);
|
|
559
|
+
} else {
|
|
560
|
+
const geometry = boxGeometry(scan.acs[o.acIndex]);
|
|
561
|
+
if ("unknown" in geometry) unknown(geometry.unknown);
|
|
562
|
+
else {
|
|
563
|
+
let m = measured.get(paragraph);
|
|
564
|
+
if (m === undefined) {
|
|
565
|
+
m = measureParagraph({ paragraph, box: geometry.box, layout: /** @type {ReturnType<typeof layoutResolver>} */ (layout), values, preserve });
|
|
566
|
+
measured.set(paragraph, m);
|
|
567
|
+
}
|
|
568
|
+
if ("unknown" in m) unknown(m.unknown);
|
|
569
|
+
else if ("overflow" in m) {
|
|
570
|
+
out.status = "overflows";
|
|
571
|
+
out.reason = m.overflow.code;
|
|
572
|
+
} else decide(o.name, m.lines, runs === 1);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (others.length > 0) out.paragraphTags = others;
|
|
576
|
+
byName.set(o.name, [...(byName.get(o.name) ?? []), out]);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** @type {FitTag[]} */
|
|
580
|
+
const tags = tagInfo(scan).map(({ name }) => {
|
|
581
|
+
const occurrences = /** @type {FitOccurrence[]} */ (byName.get(name));
|
|
582
|
+
const status = occurrences.reduce((worst, x) => (SEVERITY[x.status] > SEVERITY[worst] ? x.status : worst), /** @type {FitStatus} */ ("empty"));
|
|
583
|
+
return { name, status, occurrences };
|
|
584
|
+
});
|
|
585
|
+
const all = tags.flatMap((t) => t.occurrences);
|
|
586
|
+
/** @param {FitStatus} s */
|
|
587
|
+
const count = (s) => all.filter((x) => x.status === s).length;
|
|
588
|
+
const counts = { tags: tags.length, occurrences: all.length, fits: count("fits"), overflows: count("overflows"), unknown: count("unknown"), empty: count("empty") };
|
|
589
|
+
const source = CALIBRI_METRICS.source.regular;
|
|
590
|
+
/** @type {FitReport} */
|
|
591
|
+
const report = {
|
|
592
|
+
ok: counts.overflows === 0 && counts.unknown === 0,
|
|
593
|
+
tags,
|
|
594
|
+
counts,
|
|
595
|
+
engine: { version: ENGINE_VERSION, metrics: { family: CALIBRI_METRICS.family, carlito: source.carlito.version, calibri: source.calibri.version } },
|
|
596
|
+
};
|
|
597
|
+
return devFreeze(report);
|
|
598
|
+
}
|