@stll/docx-utils 0.1.1 → 0.1.2

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/index.d.ts CHANGED
@@ -6,7 +6,7 @@ import JSZip from "jszip";
6
6
  * Canonical source for namespace constants shared across
7
7
  * the editor (folio) and the backend (api).
8
8
  */
9
- declare const OOXML_NS: {
9
+ export declare const OOXML_NS: {
10
10
  readonly w: "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
11
11
  readonly a: "http://schemas.openxmlformats.org/drawingml/2006/main";
12
12
  readonly r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
@@ -29,7 +29,7 @@ type OoxmlPrefix = keyof typeof OOXML_NS;
29
29
  //#endregion
30
30
  //#region src/zip.d.ts
31
31
  /** Standard DOCX compression options */
32
- declare const DOCX_COMPRESSION: {
32
+ export declare const DOCX_COMPRESSION: {
33
33
  type: "arraybuffer";
34
34
  compression: "DEFLATE";
35
35
  compressionOptions: {
@@ -37,20 +37,59 @@ declare const DOCX_COMPRESSION: {
37
37
  };
38
38
  };
39
39
  /** Load a DOCX file (ArrayBuffer) into a JSZip instance */
40
- declare const loadDocx: (buffer: ArrayBuffer) => Promise<JSZip>;
40
+ export declare const loadDocx: (buffer: ArrayBuffer) => Promise<JSZip>;
41
41
  /** Extract a text file from a ZIP */
42
- declare const extractText: (zip: JSZip, path: string) => Promise<string | null>;
42
+ export declare const extractText: (zip: JSZip, path: string) => Promise<string | null>;
43
43
  /** Extract a binary file from a ZIP */
44
- declare const extractBinary: (zip: JSZip, path: string) => Promise<ArrayBuffer | null>;
44
+ export declare const extractBinary: (zip: JSZip, path: string) => Promise<ArrayBuffer | null>;
45
45
  /** Repack a ZIP to ArrayBuffer with standard DOCX compression */
46
- declare const repackZip: (zip: JSZip) => Promise<ArrayBuffer>;
46
+ export declare const repackZip: (zip: JSZip) => Promise<ArrayBuffer>;
47
47
  //#endregion
48
48
  //#region src/relationships.d.ts
49
49
  /** Find the next available rId in a relationships XML string */
50
- declare const findNextRId: (relsXml: string) => string;
50
+ export declare const findNextRId: (relsXml: string) => string;
51
51
  /** Ensure a content type entry exists in [Content_Types].xml */
52
- declare const ensureContentType: (contentTypesXml: string, partName: string, contentType: string) => string;
52
+ export declare const ensureContentType: (contentTypesXml: string, partName: string, contentType: string) => string;
53
53
  /** Ensure a relationship entry exists */
54
- declare const ensureRelationship: (relsXml: string, rId: string, type: string, target: string) => string;
54
+ export declare const ensureRelationship: (relsXml: string, rId: string, type: string, target: string) => string;
55
55
  //#endregion
56
- export { DOCX_COMPRESSION, OOXML_NS, type OoxmlPrefix, ensureContentType, ensureRelationship, extractBinary, extractText, findNextRId, loadDocx, repackZip };
56
+ //#region src/attached-template.d.ts
57
+ export declare const ATTACHED_TEMPLATE_SECURITY_RULE: "ooxml_attached_template";
58
+ export declare const ATTACHED_TEMPLATE_TARGET_KIND: {
59
+ readonly local: "local";
60
+ readonly network: "network";
61
+ readonly unknown: "unknown";
62
+ };
63
+ type AttachedTemplateTargetKind = (typeof ATTACHED_TEMPLATE_TARGET_KIND)[keyof typeof ATTACHED_TEMPLATE_TARGET_KIND];
64
+ type AttachedTemplateRelationshipFinding = {
65
+ rule: typeof ATTACHED_TEMPLATE_SECURITY_RULE;
66
+ targetKind: AttachedTemplateTargetKind;
67
+ };
68
+ type SanitizedAttachedTemplateRelationships = {
69
+ findings: readonly AttachedTemplateRelationshipFinding[];
70
+ removedRelationshipIds: readonly string[];
71
+ sourcePartPath: string | null;
72
+ xml: string;
73
+ };
74
+ type SanitizedAttachedTemplateSource = {
75
+ removed: number;
76
+ xml: string;
77
+ };
78
+ /**
79
+ * Classify where Word would resolve an attached template without retaining or
80
+ * returning the target itself. Template targets can contain usernames, host
81
+ * names, and internal paths, none of which belong in findings or telemetry.
82
+ */
83
+ export declare const classifyAttachedTemplateTarget: (target: string | null) => AttachedTemplateTargetKind;
84
+ /** Resolve `word/_rels/settings.xml.rels` to its owning `word/settings.xml`. */
85
+ export declare const relationshipSourcePartPath: (relationshipsPartPath: string) => string | null;
86
+ export declare const isOpcRelationshipPartPath: (path: string) => boolean;
87
+ /**
88
+ * Find and remove every attached-template relationship in one `.rels` part.
89
+ * Unchanged XML is returned byte-for-byte when no relationship is present.
90
+ */
91
+ export declare const sanitizeAttachedTemplateRelationships: (xml: string, relationshipsPartPath: string) => SanitizedAttachedTemplateRelationships;
92
+ /** Remove WordprocessingML references to the relationships removed above. */
93
+ export declare const sanitizeAttachedTemplateSource: (xml: string, removedRelationshipIds: readonly string[]) => SanitizedAttachedTemplateSource;
94
+ //#endregion
95
+ export type { AttachedTemplateRelationshipFinding, AttachedTemplateTargetKind, OoxmlPrefix };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import JSZip from "jszip";
2
+ import * as slimdom from "slimdom";
2
3
  //#region src/namespaces.ts
3
4
  /**
4
5
  * Common OOXML namespace URIs.
@@ -82,4 +83,127 @@ const ensureRelationship = (relsXml, rId, type, target) => {
82
83
  return relsXml.replace("</Relationships>", () => `${rel}\n</Relationships>`);
83
84
  };
84
85
  //#endregion
85
- export { DOCX_COMPRESSION, OOXML_NS, ensureContentType, ensureRelationship, extractBinary, extractText, findNextRId, loadDocx, repackZip };
86
+ //#region src/attached-template.ts
87
+ const ATTACHED_TEMPLATE_SECURITY_RULE = "ooxml_attached_template";
88
+ const ATTACHED_TEMPLATE_TARGET_KIND = {
89
+ local: "local",
90
+ network: "network",
91
+ unknown: "unknown"
92
+ };
93
+ const PACKAGE_RELATIONSHIPS_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships";
94
+ const ATTACHED_TEMPLATE_RELATIONSHIP_TYPES = ["http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate", "http://purl.oclc.org/ooxml/officeDocument/relationships/attachedTemplate"];
95
+ const WORDPROCESSINGML_NAMESPACES = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
96
+ const OFFICE_RELATIONSHIPS_NAMESPACES = ["http://schemas.openxmlformats.org/officeDocument/2006/relationships", "http://purl.oclc.org/ooxml/officeDocument/relationships"];
97
+ const RELATIONSHIPS_SUFFIX = ".rels";
98
+ const RELATIONSHIPS_DIRECTORY = "_rels";
99
+ const ROOT_RELATIONSHIPS_PATH = "_rels/.rels";
100
+ const WINDOWS_DRIVE_PATH_RE = /^[a-z]:[\\/]/iu;
101
+ const UNC_PATH_RE = /^(?:\\\\|\/\/)[^\\/]/u;
102
+ const isAttachedTemplateRelationship = (element) => {
103
+ if (element.localName !== "Relationship" || element.namespaceURI !== PACKAGE_RELATIONSHIPS_NAMESPACE) return false;
104
+ const type = element.getAttribute("Type")?.trim();
105
+ return ATTACHED_TEMPLATE_RELATIONSHIP_TYPES.some((attachedTemplateType) => attachedTemplateType === type);
106
+ };
107
+ const descendantElements = (root) => {
108
+ const elements = [];
109
+ const pending = [...root.childNodes];
110
+ while (pending.length > 0) {
111
+ const node = pending.pop();
112
+ if (node === void 0) break;
113
+ if (node instanceof slimdom.Element) elements.push(node);
114
+ for (const child of node.childNodes) pending.push(child);
115
+ }
116
+ return elements;
117
+ };
118
+ /**
119
+ * Classify where Word would resolve an attached template without retaining or
120
+ * returning the target itself. Template targets can contain usernames, host
121
+ * names, and internal paths, none of which belong in findings or telemetry.
122
+ */
123
+ const classifyAttachedTemplateTarget = (target) => {
124
+ const normalized = target?.trim();
125
+ if (!normalized || normalized.includes("\0")) return ATTACHED_TEMPLATE_TARGET_KIND.unknown;
126
+ if (UNC_PATH_RE.test(normalized)) return ATTACHED_TEMPLATE_TARGET_KIND.network;
127
+ if (WINDOWS_DRIVE_PATH_RE.test(normalized)) return ATTACHED_TEMPLATE_TARGET_KIND.local;
128
+ if (!URL.canParse(normalized)) return ATTACHED_TEMPLATE_TARGET_KIND.local;
129
+ const url = new URL(normalized);
130
+ if (url.protocol !== "file:") return ATTACHED_TEMPLATE_TARGET_KIND.network;
131
+ if (url.pathname.startsWith("//")) return ATTACHED_TEMPLATE_TARGET_KIND.network;
132
+ return url.hostname === "" || url.hostname.toLowerCase() === "localhost" ? ATTACHED_TEMPLATE_TARGET_KIND.local : ATTACHED_TEMPLATE_TARGET_KIND.network;
133
+ };
134
+ /** Resolve `word/_rels/settings.xml.rels` to its owning `word/settings.xml`. */
135
+ const relationshipSourcePartPath = (relationshipsPartPath) => {
136
+ if (relationshipsPartPath.includes("\\") || relationshipsPartPath.startsWith("/")) return null;
137
+ if (relationshipsPartPath === ROOT_RELATIONSHIPS_PATH) return null;
138
+ const segments = relationshipsPartPath.split("/");
139
+ if (segments.length < 2 || segments.some((segment) => segment === "" || segment === "." || segment === "..")) return null;
140
+ const relationshipDirectory = segments.at(-2);
141
+ const relationshipFileName = segments.at(-1);
142
+ if (relationshipDirectory !== RELATIONSHIPS_DIRECTORY || relationshipFileName === void 0 || !relationshipFileName.endsWith(RELATIONSHIPS_SUFFIX)) return null;
143
+ const sourceFileName = relationshipFileName.slice(0, -5);
144
+ if (sourceFileName === "") return null;
145
+ return [...segments.slice(0, -2), sourceFileName].join("/");
146
+ };
147
+ const isOpcRelationshipPartPath = (path) => path === ROOT_RELATIONSHIPS_PATH || relationshipSourcePartPath(path) !== null;
148
+ /**
149
+ * Find and remove every attached-template relationship in one `.rels` part.
150
+ * Unchanged XML is returned byte-for-byte when no relationship is present.
151
+ */
152
+ const sanitizeAttachedTemplateRelationships = (xml, relationshipsPartPath) => {
153
+ const document = slimdom.parseXmlDocument(xml);
154
+ const relationships = descendantElements(document).filter(isAttachedTemplateRelationship);
155
+ if (relationships.length === 0) return {
156
+ findings: [],
157
+ removedRelationshipIds: [],
158
+ sourcePartPath: relationshipSourcePartPath(relationshipsPartPath),
159
+ xml
160
+ };
161
+ const findings = relationships.map((relationship) => ({
162
+ rule: ATTACHED_TEMPLATE_SECURITY_RULE,
163
+ targetKind: classifyAttachedTemplateTarget(relationship.getAttribute("Target"))
164
+ }));
165
+ const removedRelationshipIds = relationships.flatMap((relationship) => {
166
+ const id = relationship.getAttribute("Id")?.trim();
167
+ return id ? [id] : [];
168
+ });
169
+ for (const relationship of relationships) relationship.remove();
170
+ return {
171
+ findings,
172
+ removedRelationshipIds,
173
+ sourcePartPath: relationshipSourcePartPath(relationshipsPartPath),
174
+ xml: slimdom.serializeToWellFormedString(document)
175
+ };
176
+ };
177
+ const relationshipReferenceId = (element) => {
178
+ for (const namespace of OFFICE_RELATIONSHIPS_NAMESPACES) {
179
+ const id = element.getAttributeNS(namespace, "id");
180
+ if (id !== null) return id;
181
+ }
182
+ return null;
183
+ };
184
+ /** Remove WordprocessingML references to the relationships removed above. */
185
+ const sanitizeAttachedTemplateSource = (xml, removedRelationshipIds) => {
186
+ if (removedRelationshipIds.length === 0) return {
187
+ removed: 0,
188
+ xml
189
+ };
190
+ const document = slimdom.parseXmlDocument(xml);
191
+ const root = document.documentElement;
192
+ if (root?.localName !== "settings" || !WORDPROCESSINGML_NAMESPACES.some((namespace) => namespace === root.namespaceURI)) return {
193
+ removed: 0,
194
+ xml
195
+ };
196
+ const relationshipIds = new Set(removedRelationshipIds);
197
+ const references = descendantElements(document).filter((element) => element.localName === "attachedTemplate" && element.namespaceURI === root.namespaceURI && relationshipIds.has(relationshipReferenceId(element) ?? ""));
198
+ if (references.length === 0) return {
199
+ removed: 0,
200
+ xml
201
+ };
202
+ for (const reference of references) reference.remove();
203
+ return {
204
+ removed: references.length,
205
+ xml: slimdom.serializeToWellFormedString(document)
206
+ };
207
+ };
208
+ //#endregion
209
+ export { ATTACHED_TEMPLATE_SECURITY_RULE, ATTACHED_TEMPLATE_TARGET_KIND, DOCX_COMPRESSION, OOXML_NS, classifyAttachedTemplateTarget, ensureContentType, ensureRelationship, extractBinary, extractText, findNextRId, isOpcRelationshipPartPath, loadDocx, relationshipSourcePartPath, repackZip, sanitizeAttachedTemplateRelationships, sanitizeAttachedTemplateSource };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/docx-utils",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Low-level helpers for reading and writing DOCX/OOXML zip packages: text and binary extraction, relationship and content-type management, and namespace constants.",
5
5
  "keywords": [
6
6
  "docx",
@@ -43,16 +43,17 @@
43
43
  "typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
44
44
  "lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/docx-utils",
45
45
  "lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/docx-utils",
46
- "format": "oxfmt .",
46
+ "format": "bun ../../scripts/run-oxfmt.ts .",
47
47
  "prepack": "bun run build"
48
48
  },
49
49
  "dependencies": {
50
- "jszip": "3.10.1"
50
+ "jszip": "3.10.1",
51
+ "slimdom": "^4.3.5"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@stll/typescript-config": "0.0.0",
54
- "@types/bun": "1.3.14",
55
- "tsdown": "0.22.14"
55
+ "bun-types": "1.4.2",
56
+ "tsdown": "0.23.0"
56
57
  },
57
58
  "main": "./dist/index.js",
58
59
  "types": "./dist/index.d.ts"