@lotics/docx 0.1.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/package.json +40 -0
- package/src/fixtures/.gitkeep +0 -0
- package/src/fixtures/lotics_generated_contract.docx +0 -0
- package/src/fonts/bundled.ts +123 -0
- package/src/fonts/registry.test.ts +233 -0
- package/src/fonts/registry.ts +219 -0
- package/src/fonts/types.ts +83 -0
- package/src/index.ts +16 -0
- package/src/layout/engine.test.ts +430 -0
- package/src/layout/engine.ts +566 -0
- package/src/layout/page_geometry.ts +43 -0
- package/src/layout/types.ts +159 -0
- package/src/load.test.ts +144 -0
- package/src/load.ts +142 -0
- package/src/model/default_numbering.ts +101 -0
- package/src/model/default_styles.ts +201 -0
- package/src/model/numbering_table.ts +52 -0
- package/src/model/properties.ts +328 -0
- package/src/model/sections.ts +94 -0
- package/src/model/style_resolution.test.ts +219 -0
- package/src/model/style_resolution.ts +113 -0
- package/src/model/style_table.ts +22 -0
- package/src/model/theme.ts +156 -0
- package/src/model/types.ts +55 -0
- package/src/parse/drawing.ts +157 -0
- package/src/parse/font_table.ts +132 -0
- package/src/parse/footnotes.ts +60 -0
- package/src/parse/header_footer.test.ts +264 -0
- package/src/parse/header_footer.ts +66 -0
- package/src/parse/numbering.ts +187 -0
- package/src/parse/parser.ts +184 -0
- package/src/parse/relationships.ts +83 -0
- package/src/parse/sections.test.ts +192 -0
- package/src/parse/sections.ts +182 -0
- package/src/parse/styles.ts +149 -0
- package/src/parse/theme.test.ts +86 -0
- package/src/parse/theme.ts +112 -0
- package/src/pm/bubble_menu.ts +117 -0
- package/src/pm/commands.test.ts +185 -0
- package/src/pm/commands.ts +697 -0
- package/src/pm/commands_insert.test.ts +183 -0
- package/src/pm/docx_to_pm.test.ts +330 -0
- package/src/pm/docx_to_pm.ts +643 -0
- package/src/pm/drag_handle.ts +166 -0
- package/src/pm/format_painter.test.ts +91 -0
- package/src/pm/format_painter.ts +109 -0
- package/src/pm/header_footer_doc.ts +24 -0
- package/src/pm/hyperlinks.test.ts +234 -0
- package/src/pm/image_registry.test.ts +81 -0
- package/src/pm/image_registry.ts +100 -0
- package/src/pm/images.test.ts +257 -0
- package/src/pm/link_popover.ts +159 -0
- package/src/pm/mark_commands.ts +60 -0
- package/src/pm/marks.ts +169 -0
- package/src/pm/nodes.ts +258 -0
- package/src/pm/numbering.test.ts +210 -0
- package/src/pm/numbering_plugin.test.ts +71 -0
- package/src/pm/numbering_plugin.ts +96 -0
- package/src/pm/outline.ts +41 -0
- package/src/pm/page_break.test.ts +80 -0
- package/src/pm/page_layout.test.ts +87 -0
- package/src/pm/pagination_plugin.test.ts +155 -0
- package/src/pm/pagination_plugin.ts +590 -0
- package/src/pm/phase5.test.ts +271 -0
- package/src/pm/phase6.test.ts +215 -0
- package/src/pm/placeholder_plugin.ts +24 -0
- package/src/pm/plugins.ts +91 -0
- package/src/pm/pm_to_docx.ts +0 -0
- package/src/pm/roundtrip.test.ts +332 -0
- package/src/pm/schema.test.ts +188 -0
- package/src/pm/schema.ts +79 -0
- package/src/pm/search.ts +46 -0
- package/src/pm/table_attrs.ts +48 -0
- package/src/pm/table_borders.test.ts +117 -0
- package/src/pm/table_borders.ts +130 -0
- package/src/pm/table_convert.test.ts +221 -0
- package/src/pm/table_convert.ts +541 -0
- package/src/pm/table_decorations.ts +132 -0
- package/src/pm/table_handles.ts +163 -0
- package/src/pm/template_marker.ts +47 -0
- package/src/pm/template_plugin.ts +65 -0
- package/src/pm/templates.test.ts +162 -0
- package/src/render/clipboard.test.ts +115 -0
- package/src/render/clipboard.ts +200 -0
- package/src/render/editable_view.test.ts +173 -0
- package/src/render/footnotes_view.ts +94 -0
- package/src/render/header_footer_view.ts +95 -0
- package/src/render/link_mark_view.ts +26 -0
- package/src/render/media_resolver.ts +61 -0
- package/src/render/node_views.ts +296 -0
- package/src/render/numbering_counter.ts +149 -0
- package/src/render/page_chrome.test.ts +262 -0
- package/src/render/page_chrome.ts +343 -0
- package/src/render/page_styles.ts +234 -0
- package/src/render/paragraph_view.test.ts +162 -0
- package/src/render/paragraph_view.ts +141 -0
- package/src/render/ruler.ts +110 -0
- package/src/render/style_registry.ts +33 -0
- package/src/render/table_dom.test.ts +171 -0
- package/src/render/table_dom.ts +288 -0
- package/src/render/units.ts +18 -0
- package/src/render/view.test.ts +165 -0
- package/src/render/view.ts +607 -0
- package/src/roundtrip.test.ts +179 -0
- package/src/serialize/default_parts.ts +128 -0
- package/src/serialize/header_footer_pm.ts +82 -0
- package/src/serialize/serializer.ts +114 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lotics/docx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./*": "./src/*.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsgo --noEmit",
|
|
17
|
+
"lint": "oxlint",
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@lotics/ooxml": "^0.1.0",
|
|
22
|
+
"fast-xml-parser": "^4.4.1",
|
|
23
|
+
"jszip": "^3.10.1",
|
|
24
|
+
"prosemirror-commands": "^1.7.1",
|
|
25
|
+
"prosemirror-dropcursor": "^1.8.2",
|
|
26
|
+
"prosemirror-gapcursor": "^1.4.1",
|
|
27
|
+
"prosemirror-history": "^1.5.0",
|
|
28
|
+
"prosemirror-inputrules": "^1.5.1",
|
|
29
|
+
"prosemirror-keymap": "^1.2.3",
|
|
30
|
+
"prosemirror-model": "^1.25.4",
|
|
31
|
+
"prosemirror-search": "^1.1.0",
|
|
32
|
+
"prosemirror-state": "^1.4.4",
|
|
33
|
+
"prosemirror-tables": "^1.8.5",
|
|
34
|
+
"prosemirror-view": "^1.41.8"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"happy-dom": "^20.9.0",
|
|
38
|
+
"vitest": "^4.1.7"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { BundledFont } from "./types";
|
|
2
|
+
|
|
3
|
+
export type BundledFontManifest = {
|
|
4
|
+
fonts: ReadonlyMap<string, BundledFont>;
|
|
5
|
+
metricSubstitutes: ReadonlyMap<string, string>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const DEFAULT_BUNDLED_FONTS: readonly BundledFont[] = [
|
|
9
|
+
{
|
|
10
|
+
family: "Carlito",
|
|
11
|
+
regularUrl: "/fonts/carlito/Carlito-Regular.woff2",
|
|
12
|
+
boldUrl: "/fonts/carlito/Carlito-Bold.woff2",
|
|
13
|
+
italicUrl: "/fonts/carlito/Carlito-Italic.woff2",
|
|
14
|
+
boldItalicUrl: "/fonts/carlito/Carlito-BoldItalic.woff2",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
family: "Caladea",
|
|
18
|
+
regularUrl: "/fonts/caladea/Caladea-Regular.woff2",
|
|
19
|
+
boldUrl: "/fonts/caladea/Caladea-Bold.woff2",
|
|
20
|
+
italicUrl: "/fonts/caladea/Caladea-Italic.woff2",
|
|
21
|
+
boldItalicUrl: "/fonts/caladea/Caladea-BoldItalic.woff2",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
family: "Liberation Sans",
|
|
25
|
+
regularUrl: "/fonts/liberation-sans/LiberationSans-Regular.woff2",
|
|
26
|
+
boldUrl: "/fonts/liberation-sans/LiberationSans-Bold.woff2",
|
|
27
|
+
italicUrl: "/fonts/liberation-sans/LiberationSans-Italic.woff2",
|
|
28
|
+
boldItalicUrl: "/fonts/liberation-sans/LiberationSans-BoldItalic.woff2",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
family: "Liberation Serif",
|
|
32
|
+
regularUrl: "/fonts/liberation-serif/LiberationSerif-Regular.woff2",
|
|
33
|
+
boldUrl: "/fonts/liberation-serif/LiberationSerif-Bold.woff2",
|
|
34
|
+
italicUrl: "/fonts/liberation-serif/LiberationSerif-Italic.woff2",
|
|
35
|
+
boldItalicUrl: "/fonts/liberation-serif/LiberationSerif-BoldItalic.woff2",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
family: "Liberation Mono",
|
|
39
|
+
regularUrl: "/fonts/liberation-mono/LiberationMono-Regular.woff2",
|
|
40
|
+
boldUrl: "/fonts/liberation-mono/LiberationMono-Bold.woff2",
|
|
41
|
+
italicUrl: "/fonts/liberation-mono/LiberationMono-Italic.woff2",
|
|
42
|
+
boldItalicUrl: "/fonts/liberation-mono/LiberationMono-BoldItalic.woff2",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
family: "Open Sans",
|
|
46
|
+
regularUrl: "/fonts/open-sans/OpenSans-Regular.woff2",
|
|
47
|
+
boldUrl: "/fonts/open-sans/OpenSans-Bold.woff2",
|
|
48
|
+
italicUrl: "/fonts/open-sans/OpenSans-Italic.woff2",
|
|
49
|
+
boldItalicUrl: "/fonts/open-sans/OpenSans-BoldItalic.woff2",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
family: "Roboto",
|
|
53
|
+
regularUrl: "/fonts/roboto/Roboto-Regular.woff2",
|
|
54
|
+
boldUrl: "/fonts/roboto/Roboto-Bold.woff2",
|
|
55
|
+
italicUrl: "/fonts/roboto/Roboto-Italic.woff2",
|
|
56
|
+
boldItalicUrl: "/fonts/roboto/Roboto-BoldItalic.woff2",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
family: "Lato",
|
|
60
|
+
regularUrl: "/fonts/lato/Lato-Regular.woff2",
|
|
61
|
+
boldUrl: "/fonts/lato/Lato-Bold.woff2",
|
|
62
|
+
italicUrl: "/fonts/lato/Lato-Italic.woff2",
|
|
63
|
+
boldItalicUrl: "/fonts/lato/Lato-BoldItalic.woff2",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const DEFAULT_METRIC_SUBSTITUTES: ReadonlyArray<readonly [string, string]> = [
|
|
68
|
+
["Calibri", "Carlito"],
|
|
69
|
+
["Calibri Light", "Carlito"],
|
|
70
|
+
["Cambria", "Caladea"],
|
|
71
|
+
["Cambria Math", "Caladea"],
|
|
72
|
+
["Aptos", "Carlito"],
|
|
73
|
+
["Aptos Display", "Carlito"],
|
|
74
|
+
["Arial", "Liberation Sans"],
|
|
75
|
+
["Helvetica", "Liberation Sans"],
|
|
76
|
+
["Times New Roman", "Liberation Serif"],
|
|
77
|
+
["Times", "Liberation Serif"],
|
|
78
|
+
["Courier New", "Liberation Mono"],
|
|
79
|
+
["Courier", "Liberation Mono"],
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
function normalize(family: string): string {
|
|
83
|
+
return family.trim().toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildBundledManifest(
|
|
87
|
+
fonts: readonly BundledFont[] = DEFAULT_BUNDLED_FONTS,
|
|
88
|
+
substitutes: ReadonlyArray<readonly [string, string]> = DEFAULT_METRIC_SUBSTITUTES,
|
|
89
|
+
additional: readonly BundledFont[] = [],
|
|
90
|
+
): BundledFontManifest {
|
|
91
|
+
const fontMap = new Map<string, BundledFont>();
|
|
92
|
+
for (const font of fonts) {
|
|
93
|
+
fontMap.set(normalize(font.family), font);
|
|
94
|
+
}
|
|
95
|
+
for (const font of additional) {
|
|
96
|
+
fontMap.set(normalize(font.family), font);
|
|
97
|
+
}
|
|
98
|
+
const subMap = new Map<string, string>();
|
|
99
|
+
for (const [from, to] of substitutes) {
|
|
100
|
+
subMap.set(normalize(from), to);
|
|
101
|
+
}
|
|
102
|
+
return { fonts: fontMap, metricSubstitutes: subMap };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function lookupBundled(
|
|
106
|
+
manifest: BundledFontManifest,
|
|
107
|
+
family: string,
|
|
108
|
+
): BundledFont | null {
|
|
109
|
+
const direct = manifest.fonts.get(normalize(family));
|
|
110
|
+
if (direct) return direct;
|
|
111
|
+
const substitute = manifest.metricSubstitutes.get(normalize(family));
|
|
112
|
+
if (!substitute) return null;
|
|
113
|
+
return manifest.fonts.get(normalize(substitute)) ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getMetricSubstitute(
|
|
117
|
+
manifest: BundledFontManifest,
|
|
118
|
+
family: string,
|
|
119
|
+
): string | null {
|
|
120
|
+
return manifest.metricSubstitutes.get(normalize(family)) ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const FALLBACK_BUNDLED_FAMILY = "Liberation Serif";
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildFontRegistry } from "./registry";
|
|
3
|
+
import {
|
|
4
|
+
buildBundledManifest,
|
|
5
|
+
lookupBundled,
|
|
6
|
+
getMetricSubstitute,
|
|
7
|
+
} from "./bundled";
|
|
8
|
+
import { parseFontTable } from "../parse/font_table";
|
|
9
|
+
|
|
10
|
+
describe("bundled manifest", () => {
|
|
11
|
+
it("maps Calibri → Carlito (metric substitute)", () => {
|
|
12
|
+
const m = buildBundledManifest();
|
|
13
|
+
expect(getMetricSubstitute(m, "Calibri")).toBe("Carlito");
|
|
14
|
+
const bundled = lookupBundled(m, "Calibri");
|
|
15
|
+
expect(bundled?.family).toBe("Carlito");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("maps Cambria → Caladea, Aptos → Carlito", () => {
|
|
19
|
+
const m = buildBundledManifest();
|
|
20
|
+
expect(getMetricSubstitute(m, "Cambria")).toBe("Caladea");
|
|
21
|
+
expect(getMetricSubstitute(m, "Aptos")).toBe("Carlito");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns the bundled font directly when name matches", () => {
|
|
25
|
+
const m = buildBundledManifest();
|
|
26
|
+
expect(lookupBundled(m, "Roboto")?.family).toBe("Roboto");
|
|
27
|
+
expect(getMetricSubstitute(m, "Roboto")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns null for completely unknown families", () => {
|
|
31
|
+
const m = buildBundledManifest();
|
|
32
|
+
expect(lookupBundled(m, "ZZZ Custom Font")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("font registry cascade", () => {
|
|
37
|
+
it("falls back to bundled metric substitute and flags substitutedFor", () => {
|
|
38
|
+
const reg = buildFontRegistry({
|
|
39
|
+
embeddedFonts: [],
|
|
40
|
+
workspaceFonts: [],
|
|
41
|
+
});
|
|
42
|
+
const result = reg.resolve({
|
|
43
|
+
requestedFamily: "Calibri",
|
|
44
|
+
weight: 400,
|
|
45
|
+
style: "normal",
|
|
46
|
+
});
|
|
47
|
+
expect(result.resource.family).toBe("Carlito");
|
|
48
|
+
expect(result.resource.substitutedFor).toBe("Calibri");
|
|
49
|
+
expect(result.resource.source.kind).toBe("bundled");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not flag substitutedFor when the bundled family matches the request", () => {
|
|
53
|
+
const reg = buildFontRegistry({
|
|
54
|
+
embeddedFonts: [],
|
|
55
|
+
workspaceFonts: [],
|
|
56
|
+
});
|
|
57
|
+
const result = reg.resolve({
|
|
58
|
+
requestedFamily: "Carlito",
|
|
59
|
+
weight: 400,
|
|
60
|
+
style: "normal",
|
|
61
|
+
});
|
|
62
|
+
expect(result.resource.family).toBe("Carlito");
|
|
63
|
+
expect(result.resource.substitutedFor).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("workspace fonts win over bundled metric substitutes", () => {
|
|
67
|
+
const reg = buildFontRegistry({
|
|
68
|
+
embeddedFonts: [],
|
|
69
|
+
workspaceFonts: [
|
|
70
|
+
{
|
|
71
|
+
family: "Calibri",
|
|
72
|
+
regular: { fileId: "fil_workspace_calibri" },
|
|
73
|
+
bold: null,
|
|
74
|
+
italic: null,
|
|
75
|
+
boldItalic: null,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
const result = reg.resolve({
|
|
80
|
+
requestedFamily: "Calibri",
|
|
81
|
+
weight: 400,
|
|
82
|
+
style: "normal",
|
|
83
|
+
});
|
|
84
|
+
expect(result.resource.source).toEqual({
|
|
85
|
+
kind: "workspace",
|
|
86
|
+
fileId: "fil_workspace_calibri",
|
|
87
|
+
});
|
|
88
|
+
expect(result.resource.substitutedFor).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("embedded fonts win over workspace and bundled", () => {
|
|
92
|
+
const reg = buildFontRegistry({
|
|
93
|
+
embeddedFonts: [
|
|
94
|
+
{
|
|
95
|
+
name: "Calibri",
|
|
96
|
+
altName: null,
|
|
97
|
+
panose: null,
|
|
98
|
+
family: "swiss",
|
|
99
|
+
pitch: "variable",
|
|
100
|
+
charset: null,
|
|
101
|
+
embeddedRegular: {
|
|
102
|
+
relationshipId: "rId10",
|
|
103
|
+
obfuscationKey: "{12345}",
|
|
104
|
+
},
|
|
105
|
+
embeddedBold: null,
|
|
106
|
+
embeddedItalic: null,
|
|
107
|
+
embeddedBoldItalic: null,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
workspaceFonts: [
|
|
111
|
+
{
|
|
112
|
+
family: "Calibri",
|
|
113
|
+
regular: { fileId: "fil_workspace" },
|
|
114
|
+
bold: null,
|
|
115
|
+
italic: null,
|
|
116
|
+
boldItalic: null,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
const result = reg.resolve({
|
|
121
|
+
requestedFamily: "Calibri",
|
|
122
|
+
weight: 400,
|
|
123
|
+
style: "normal",
|
|
124
|
+
});
|
|
125
|
+
expect(result.resource.source).toEqual({
|
|
126
|
+
kind: "embedded",
|
|
127
|
+
partPath: "rId10",
|
|
128
|
+
obfuscationKey: "{12345}",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("picks bold variant when weight >= 600", () => {
|
|
133
|
+
const reg = buildFontRegistry({
|
|
134
|
+
embeddedFonts: [],
|
|
135
|
+
workspaceFonts: [],
|
|
136
|
+
});
|
|
137
|
+
const result = reg.resolve({
|
|
138
|
+
requestedFamily: "Carlito",
|
|
139
|
+
weight: 700,
|
|
140
|
+
style: "normal",
|
|
141
|
+
});
|
|
142
|
+
expect(result.resource.weight).toBe(700);
|
|
143
|
+
expect(result.resource.style).toBe("normal");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("picks bold-italic when bold + italic requested", () => {
|
|
147
|
+
const reg = buildFontRegistry({
|
|
148
|
+
embeddedFonts: [],
|
|
149
|
+
workspaceFonts: [],
|
|
150
|
+
});
|
|
151
|
+
const result = reg.resolve({
|
|
152
|
+
requestedFamily: "Liberation Serif",
|
|
153
|
+
weight: 700,
|
|
154
|
+
style: "italic",
|
|
155
|
+
});
|
|
156
|
+
expect(result.resource.weight).toBe(700);
|
|
157
|
+
expect(result.resource.style).toBe("italic");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("falls back to Liberation Serif for completely unknown families", () => {
|
|
161
|
+
const reg = buildFontRegistry({
|
|
162
|
+
embeddedFonts: [],
|
|
163
|
+
workspaceFonts: [],
|
|
164
|
+
});
|
|
165
|
+
const result = reg.resolve({
|
|
166
|
+
requestedFamily: "NoSuchFontEverPlease",
|
|
167
|
+
weight: 400,
|
|
168
|
+
style: "normal",
|
|
169
|
+
});
|
|
170
|
+
expect(result.resource.family).toBe("Liberation Serif");
|
|
171
|
+
expect(result.resource.substitutedFor).toBe("NoSuchFontEverPlease");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("caches resolved variant sets across calls", () => {
|
|
175
|
+
const reg = buildFontRegistry({
|
|
176
|
+
embeddedFonts: [],
|
|
177
|
+
workspaceFonts: [],
|
|
178
|
+
});
|
|
179
|
+
const a = reg.resolve({
|
|
180
|
+
requestedFamily: "Calibri",
|
|
181
|
+
weight: 400,
|
|
182
|
+
style: "normal",
|
|
183
|
+
});
|
|
184
|
+
const b = reg.resolve({
|
|
185
|
+
requestedFamily: "Calibri",
|
|
186
|
+
weight: 700,
|
|
187
|
+
style: "italic",
|
|
188
|
+
});
|
|
189
|
+
expect(a.variantSet).toBe(b.variantSet);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("fontTable parsing", () => {
|
|
194
|
+
it("returns [] when fontTable.xml is missing", () => {
|
|
195
|
+
const decls = parseFontTable(new Map());
|
|
196
|
+
expect(decls).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("parses font entries with PANOSE and embed refs", () => {
|
|
200
|
+
const xml = `<?xml version="1.0"?>
|
|
201
|
+
<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
202
|
+
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
203
|
+
<w:font w:name="Calibri">
|
|
204
|
+
<w:panose1 w:val="020F0502020204030204"/>
|
|
205
|
+
<w:family w:val="swiss"/>
|
|
206
|
+
<w:pitch w:val="variable"/>
|
|
207
|
+
<w:embedRegular r:id="rId1" w:fontKey="{ABC}"/>
|
|
208
|
+
<w:embedBold r:id="rId2"/>
|
|
209
|
+
</w:font>
|
|
210
|
+
<w:font w:name="Times New Roman">
|
|
211
|
+
<w:family w:val="roman"/>
|
|
212
|
+
<w:pitch w:val="variable"/>
|
|
213
|
+
</w:font>
|
|
214
|
+
</w:fonts>`;
|
|
215
|
+
const parts = new Map([
|
|
216
|
+
["word/fontTable.xml", new TextEncoder().encode(xml)],
|
|
217
|
+
]);
|
|
218
|
+
const decls = parseFontTable(parts);
|
|
219
|
+
expect(decls).toHaveLength(2);
|
|
220
|
+
expect(decls[0].name).toBe("Calibri");
|
|
221
|
+
expect(decls[0].family).toBe("swiss");
|
|
222
|
+
expect(decls[0].panose?.bytes).toEqual([
|
|
223
|
+
0x02, 0x0f, 0x05, 0x02, 0x02, 0x02, 0x04, 0x03, 0x02, 0x04,
|
|
224
|
+
]);
|
|
225
|
+
expect(decls[0].embeddedRegular).toEqual({
|
|
226
|
+
relationshipId: "rId1",
|
|
227
|
+
obfuscationKey: "{ABC}",
|
|
228
|
+
});
|
|
229
|
+
expect(decls[0].embeddedBold?.obfuscationKey).toBeNull();
|
|
230
|
+
expect(decls[1].family).toBe("roman");
|
|
231
|
+
expect(decls[1].embeddedRegular).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildBundledManifest,
|
|
3
|
+
FALLBACK_BUNDLED_FAMILY,
|
|
4
|
+
lookupBundled,
|
|
5
|
+
type BundledFontManifest,
|
|
6
|
+
} from "./bundled";
|
|
7
|
+
import type {
|
|
8
|
+
BundledFont,
|
|
9
|
+
FontDeclaration,
|
|
10
|
+
FontRegistry,
|
|
11
|
+
FontResource,
|
|
12
|
+
FontVariantSet,
|
|
13
|
+
FontWeight,
|
|
14
|
+
ResolveFontInput,
|
|
15
|
+
ResolveFontResult,
|
|
16
|
+
WorkspaceFont,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
export type BuildRegistryInput = {
|
|
20
|
+
embeddedFonts: readonly FontDeclaration[];
|
|
21
|
+
workspaceFonts: readonly WorkspaceFont[];
|
|
22
|
+
bundled?: BundledFontManifest;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function normalize(family: string): string {
|
|
26
|
+
return family.trim().toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function variantFromEmbedded(
|
|
30
|
+
decl: FontDeclaration,
|
|
31
|
+
): FontVariantSet | null {
|
|
32
|
+
if (
|
|
33
|
+
!decl.embeddedRegular &&
|
|
34
|
+
!decl.embeddedBold &&
|
|
35
|
+
!decl.embeddedItalic &&
|
|
36
|
+
!decl.embeddedBoldItalic
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const make = (
|
|
41
|
+
ref: NonNullable<FontDeclaration["embeddedRegular"]>,
|
|
42
|
+
weight: FontWeight,
|
|
43
|
+
style: "normal" | "italic",
|
|
44
|
+
): FontResource => ({
|
|
45
|
+
family: decl.name,
|
|
46
|
+
style,
|
|
47
|
+
weight,
|
|
48
|
+
source: {
|
|
49
|
+
kind: "embedded",
|
|
50
|
+
partPath: ref.relationshipId,
|
|
51
|
+
obfuscationKey: ref.obfuscationKey,
|
|
52
|
+
},
|
|
53
|
+
substitutedFor: null,
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
family: decl.name,
|
|
57
|
+
regular: decl.embeddedRegular
|
|
58
|
+
? make(decl.embeddedRegular, 400, "normal")
|
|
59
|
+
: null,
|
|
60
|
+
bold: decl.embeddedBold ? make(decl.embeddedBold, 700, "normal") : null,
|
|
61
|
+
italic: decl.embeddedItalic
|
|
62
|
+
? make(decl.embeddedItalic, 400, "italic")
|
|
63
|
+
: null,
|
|
64
|
+
boldItalic: decl.embeddedBoldItalic
|
|
65
|
+
? make(decl.embeddedBoldItalic, 700, "italic")
|
|
66
|
+
: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function variantFromWorkspace(font: WorkspaceFont): FontVariantSet {
|
|
71
|
+
const make = (
|
|
72
|
+
ref: NonNullable<WorkspaceFont["regular"]>,
|
|
73
|
+
weight: FontWeight,
|
|
74
|
+
style: "normal" | "italic",
|
|
75
|
+
): FontResource => ({
|
|
76
|
+
family: font.family,
|
|
77
|
+
style,
|
|
78
|
+
weight,
|
|
79
|
+
source: { kind: "workspace", fileId: ref.fileId },
|
|
80
|
+
substitutedFor: null,
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
family: font.family,
|
|
84
|
+
regular: font.regular ? make(font.regular, 400, "normal") : null,
|
|
85
|
+
bold: font.bold ? make(font.bold, 700, "normal") : null,
|
|
86
|
+
italic: font.italic ? make(font.italic, 400, "italic") : null,
|
|
87
|
+
boldItalic: font.boldItalic ? make(font.boldItalic, 700, "italic") : null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function variantFromBundled(
|
|
92
|
+
bundled: BundledFont,
|
|
93
|
+
substitutedFor: string | null,
|
|
94
|
+
): FontVariantSet {
|
|
95
|
+
const make = (
|
|
96
|
+
url: string,
|
|
97
|
+
weight: FontWeight,
|
|
98
|
+
style: "normal" | "italic",
|
|
99
|
+
): FontResource => ({
|
|
100
|
+
family: bundled.family,
|
|
101
|
+
style,
|
|
102
|
+
weight,
|
|
103
|
+
source: { kind: "bundled", assetUrl: url },
|
|
104
|
+
substitutedFor,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
family: bundled.family,
|
|
108
|
+
regular: bundled.regularUrl
|
|
109
|
+
? make(bundled.regularUrl, 400, "normal")
|
|
110
|
+
: null,
|
|
111
|
+
bold: bundled.boldUrl ? make(bundled.boldUrl, 700, "normal") : null,
|
|
112
|
+
italic: bundled.italicUrl ? make(bundled.italicUrl, 400, "italic") : null,
|
|
113
|
+
boldItalic: bundled.boldItalicUrl
|
|
114
|
+
? make(bundled.boldItalicUrl, 700, "italic")
|
|
115
|
+
: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pickResource(
|
|
120
|
+
set: FontVariantSet,
|
|
121
|
+
weight: FontWeight,
|
|
122
|
+
style: "normal" | "italic",
|
|
123
|
+
): FontResource | null {
|
|
124
|
+
const wantBold = weight >= 600;
|
|
125
|
+
if (wantBold && style === "italic" && set.boldItalic) return set.boldItalic;
|
|
126
|
+
if (wantBold && set.bold) return set.bold;
|
|
127
|
+
if (style === "italic" && set.italic) return set.italic;
|
|
128
|
+
if (set.regular) return set.regular;
|
|
129
|
+
return set.bold ?? set.italic ?? set.boldItalic ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildFontRegistry(input: BuildRegistryInput): FontRegistry {
|
|
133
|
+
const bundled = input.bundled ?? buildBundledManifest();
|
|
134
|
+
|
|
135
|
+
const embeddedSets = new Map<string, FontVariantSet>();
|
|
136
|
+
for (const decl of input.embeddedFonts) {
|
|
137
|
+
const set = variantFromEmbedded(decl);
|
|
138
|
+
if (set) embeddedSets.set(normalize(decl.name), set);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const workspaceSets = new Map<string, FontVariantSet>();
|
|
142
|
+
for (const font of input.workspaceFonts) {
|
|
143
|
+
workspaceSets.set(normalize(font.family), variantFromWorkspace(font));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const variantCache = new Map<string, FontVariantSet>();
|
|
147
|
+
|
|
148
|
+
for (const bundledFont of bundled.fonts.values()) {
|
|
149
|
+
variantCache.set(
|
|
150
|
+
normalize(bundledFont.family),
|
|
151
|
+
variantFromBundled(bundledFont, null),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const resolveVariantSet = (requested: string): FontVariantSet => {
|
|
156
|
+
const cacheKey = normalize(requested);
|
|
157
|
+
const cached = variantCache.get(cacheKey);
|
|
158
|
+
if (cached) return cached;
|
|
159
|
+
|
|
160
|
+
const embedded = embeddedSets.get(cacheKey);
|
|
161
|
+
if (embedded) {
|
|
162
|
+
variantCache.set(cacheKey, embedded);
|
|
163
|
+
return embedded;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const workspace = workspaceSets.get(cacheKey);
|
|
167
|
+
if (workspace) {
|
|
168
|
+
variantCache.set(cacheKey, workspace);
|
|
169
|
+
return workspace;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const direct = lookupBundled(bundled, requested);
|
|
173
|
+
if (direct) {
|
|
174
|
+
const substituted =
|
|
175
|
+
normalize(direct.family) === cacheKey ? null : requested;
|
|
176
|
+
const set = variantFromBundled(direct, substituted);
|
|
177
|
+
variantCache.set(cacheKey, set);
|
|
178
|
+
return set;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fallback = lookupBundled(bundled, FALLBACK_BUNDLED_FAMILY);
|
|
182
|
+
if (!fallback) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Bundled font registry missing fallback family ${FALLBACK_BUNDLED_FAMILY}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const set = variantFromBundled(fallback, requested);
|
|
188
|
+
variantCache.set(cacheKey, set);
|
|
189
|
+
return set;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const resolve = (input: ResolveFontInput): ResolveFontResult => {
|
|
193
|
+
const variantSet = resolveVariantSet(input.requestedFamily);
|
|
194
|
+
const resource = pickResource(variantSet, input.weight, input.style);
|
|
195
|
+
if (!resource) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Font registry produced empty variant set for ${input.requestedFamily}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return { resource, variantSet };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const listVariantSets = (): FontVariantSet[] => {
|
|
204
|
+
const out: FontVariantSet[] = [];
|
|
205
|
+
const seen = new Set<string>();
|
|
206
|
+
const collect = (set: FontVariantSet) => {
|
|
207
|
+
const key = normalize(set.family);
|
|
208
|
+
if (seen.has(key)) return;
|
|
209
|
+
seen.add(key);
|
|
210
|
+
out.push(set);
|
|
211
|
+
};
|
|
212
|
+
for (const set of embeddedSets.values()) collect(set);
|
|
213
|
+
for (const set of workspaceSets.values()) collect(set);
|
|
214
|
+
for (const set of variantCache.values()) collect(set);
|
|
215
|
+
return out;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return { resolve, listVariantSets };
|
|
219
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type FontStyle = "normal" | "italic";
|
|
2
|
+
export type FontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
|
|
3
|
+
|
|
4
|
+
export type FontScript = "ascii" | "hAnsi" | "eastAsia" | "complexScript";
|
|
5
|
+
|
|
6
|
+
export type FontSource =
|
|
7
|
+
| { kind: "embedded"; partPath: string; obfuscationKey: string | null }
|
|
8
|
+
| { kind: "workspace"; fileId: string }
|
|
9
|
+
| { kind: "bundled"; assetUrl: string }
|
|
10
|
+
| { kind: "system" };
|
|
11
|
+
|
|
12
|
+
export type FontResource = {
|
|
13
|
+
family: string;
|
|
14
|
+
style: FontStyle;
|
|
15
|
+
weight: FontWeight;
|
|
16
|
+
source: FontSource;
|
|
17
|
+
substitutedFor: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type FontVariantSet = {
|
|
21
|
+
family: string;
|
|
22
|
+
regular: FontResource | null;
|
|
23
|
+
bold: FontResource | null;
|
|
24
|
+
italic: FontResource | null;
|
|
25
|
+
boldItalic: FontResource | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Panose = {
|
|
29
|
+
bytes: readonly number[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type FontFamilyKind =
|
|
33
|
+
| "roman"
|
|
34
|
+
| "swiss"
|
|
35
|
+
| "modern"
|
|
36
|
+
| "script"
|
|
37
|
+
| "decorative"
|
|
38
|
+
| "auto";
|
|
39
|
+
|
|
40
|
+
export type FontDeclaration = {
|
|
41
|
+
name: string;
|
|
42
|
+
altName: string | null;
|
|
43
|
+
panose: Panose | null;
|
|
44
|
+
family: FontFamilyKind;
|
|
45
|
+
pitch: "fixed" | "variable" | "default";
|
|
46
|
+
charset: string | null;
|
|
47
|
+
embeddedRegular: { relationshipId: string; obfuscationKey: string | null } | null;
|
|
48
|
+
embeddedBold: { relationshipId: string; obfuscationKey: string | null } | null;
|
|
49
|
+
embeddedItalic: { relationshipId: string; obfuscationKey: string | null } | null;
|
|
50
|
+
embeddedBoldItalic: { relationshipId: string; obfuscationKey: string | null } | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type WorkspaceFont = {
|
|
54
|
+
family: string;
|
|
55
|
+
regular: { fileId: string } | null;
|
|
56
|
+
bold: { fileId: string } | null;
|
|
57
|
+
italic: { fileId: string } | null;
|
|
58
|
+
boldItalic: { fileId: string } | null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type BundledFont = {
|
|
62
|
+
family: string;
|
|
63
|
+
regularUrl: string | null;
|
|
64
|
+
boldUrl: string | null;
|
|
65
|
+
italicUrl: string | null;
|
|
66
|
+
boldItalicUrl: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type ResolveFontInput = {
|
|
70
|
+
requestedFamily: string;
|
|
71
|
+
weight: FontWeight;
|
|
72
|
+
style: FontStyle;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type ResolveFontResult = {
|
|
76
|
+
resource: FontResource;
|
|
77
|
+
variantSet: FontVariantSet;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type FontRegistry = {
|
|
81
|
+
resolve(input: ResolveFontInput): ResolveFontResult;
|
|
82
|
+
listVariantSets(): readonly FontVariantSet[];
|
|
83
|
+
};
|