@segnavia/idml 0.0.0-stage → 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/LICENSE +21 -0
- package/dist/import.d.ts +13 -0
- package/dist/import.js +25 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +15 -0
- package/dist/map-book.js +47 -0
- package/dist/model.d.ts +42 -0
- package/dist/model.js +291 -0
- package/dist/refusal.js +6 -0
- package/dist/zip.js +142 -0
- package/package.json +33 -4
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 modstudio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/import.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Book } from "@segnavia/format";
|
|
2
|
+
//#region src/import.d.ts
|
|
3
|
+
export type IdmlImportOptions = Record<never, never>;
|
|
4
|
+
export interface IdmlImportReport {
|
|
5
|
+
partsRead: string[];
|
|
6
|
+
unsupportedElements: Record<string, number>;
|
|
7
|
+
}
|
|
8
|
+
export interface IdmlImportResult {
|
|
9
|
+
book: Book;
|
|
10
|
+
report: IdmlImportReport;
|
|
11
|
+
}
|
|
12
|
+
export declare function importIdml(bytes: Uint8Array, _options?: IdmlImportOptions): Promise<IdmlImportResult>;
|
|
13
|
+
//#endregion
|
package/dist/import.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { refusal } from "./refusal.js";
|
|
2
|
+
import { modelToBook } from "./map-book.js";
|
|
3
|
+
import { idmlModel } from "./model.js";
|
|
4
|
+
import { readZip } from "./zip.js";
|
|
5
|
+
//#region src/import.ts
|
|
6
|
+
const IDML_MIMETYPE = "application/vnd.adobe.indesign-idml-package";
|
|
7
|
+
async function importIdml(bytes, _options = {}) {
|
|
8
|
+
const entries = await readZip(bytes);
|
|
9
|
+
const byName = new Map(entries.map((entry) => [entry.name, entry.bytes]));
|
|
10
|
+
const mimetype = byName.get("mimetype");
|
|
11
|
+
if (!mimetype) throw refusal("the IDML mimetype entry is missing", "add the required mimetype entry");
|
|
12
|
+
const value = new TextDecoder().decode(mimetype);
|
|
13
|
+
if (value !== IDML_MIMETYPE) throw refusal(`the IDML mimetype entry is ${JSON.stringify(value)}, not ${IDML_MIMETYPE}`, "use the IDML package mimetype");
|
|
14
|
+
if (!byName.has("designmap.xml")) throw refusal("the IDML designmap.xml entry is missing", "add the required designmap.xml entry");
|
|
15
|
+
const model = idmlModel(entries);
|
|
16
|
+
return {
|
|
17
|
+
book: modelToBook(model),
|
|
18
|
+
report: {
|
|
19
|
+
partsRead: model.partsRead,
|
|
20
|
+
unsupportedElements: model.unsupportedElements
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { importIdml };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { IdmlImportOptions, IdmlImportReport, IdmlImportResult, importIdml } from "./import.js";
|
|
2
|
+
import { IDML_LIMITS, MAX_IDML_ENTRIES, MAX_IDML_PACKAGE_BYTES, MAX_IDML_PART_BYTES, MAX_IDML_TOTAL_INFLATED_BYTES, MAX_IDML_XML_DEPTH } from "./limits.js";
|
|
3
|
+
import { IdmlDocumentPreferences, IdmlMargin, IdmlMasterSpread, IdmlModel, IdmlPage, IdmlSpread, IdmlStory } from "./model.js";
|
|
4
|
+
export { IDML_LIMITS, type IdmlDocumentPreferences, type IdmlImportOptions, type IdmlImportReport, type IdmlImportResult, type IdmlMargin, type IdmlMasterSpread, type IdmlModel, type IdmlPage, type IdmlSpread, type IdmlStory, MAX_IDML_ENTRIES, MAX_IDML_PACKAGE_BYTES, MAX_IDML_PART_BYTES, MAX_IDML_TOTAL_INFLATED_BYTES, MAX_IDML_XML_DEPTH, importIdml };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { IDML_LIMITS, MAX_IDML_ENTRIES, MAX_IDML_PACKAGE_BYTES, MAX_IDML_PART_BYTES, MAX_IDML_TOTAL_INFLATED_BYTES, MAX_IDML_XML_DEPTH } from "./limits.js";
|
|
2
|
+
import { importIdml } from "./import.js";
|
|
3
|
+
export { IDML_LIMITS, MAX_IDML_ENTRIES, MAX_IDML_PACKAGE_BYTES, MAX_IDML_PART_BYTES, MAX_IDML_TOTAL_INFLATED_BYTES, MAX_IDML_XML_DEPTH, importIdml };
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/limits.d.ts
|
|
2
|
+
export declare const MAX_IDML_PACKAGE_BYTES: number;
|
|
3
|
+
export declare const MAX_IDML_ENTRIES = 4096;
|
|
4
|
+
export declare const MAX_IDML_PART_BYTES: number;
|
|
5
|
+
export declare const MAX_IDML_TOTAL_INFLATED_BYTES: number;
|
|
6
|
+
export declare const MAX_IDML_XML_DEPTH = 256;
|
|
7
|
+
export interface IdmlLimits {
|
|
8
|
+
packageBytes: number;
|
|
9
|
+
entries: number;
|
|
10
|
+
partBytes: number;
|
|
11
|
+
totalInflatedBytes: number;
|
|
12
|
+
xmlDepth: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const IDML_LIMITS: Readonly<IdmlLimits>;
|
|
15
|
+
//#endregion
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/limits.ts
|
|
2
|
+
const MAX_IDML_PACKAGE_BYTES = 67108864;
|
|
3
|
+
const MAX_IDML_ENTRIES = 4096;
|
|
4
|
+
const MAX_IDML_PART_BYTES = 33554432;
|
|
5
|
+
const MAX_IDML_TOTAL_INFLATED_BYTES = 268435456;
|
|
6
|
+
const MAX_IDML_XML_DEPTH = 256;
|
|
7
|
+
const IDML_LIMITS = {
|
|
8
|
+
packageBytes: MAX_IDML_PACKAGE_BYTES,
|
|
9
|
+
entries: MAX_IDML_ENTRIES,
|
|
10
|
+
partBytes: MAX_IDML_PART_BYTES,
|
|
11
|
+
totalInflatedBytes: MAX_IDML_TOTAL_INFLATED_BYTES,
|
|
12
|
+
xmlDepth: 256
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { IDML_LIMITS, MAX_IDML_ENTRIES, MAX_IDML_PACKAGE_BYTES, MAX_IDML_PART_BYTES, MAX_IDML_TOTAL_INFLATED_BYTES, MAX_IDML_XML_DEPTH };
|
package/dist/map-book.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { refusal } from "./refusal.js";
|
|
2
|
+
import { Book, MM_PER_PT } from "@segnavia/format";
|
|
3
|
+
import { findActiveContent } from "@segnavia/html";
|
|
4
|
+
import serialize from "dom-serializer";
|
|
5
|
+
import { Element } from "domhandler";
|
|
6
|
+
//#region src/map-book.ts
|
|
7
|
+
const emptyParagraph = () => serialize(new Element("p", {}, []), { xmlMode: false });
|
|
8
|
+
function orderedStories(model) {
|
|
9
|
+
const order = /* @__PURE__ */ new Map();
|
|
10
|
+
for (const spread of model.spreads) for (const page of spread.pages) for (const story of page.stories) if (!order.has(story)) order.set(story, order.size);
|
|
11
|
+
const masterStories = new Set(model.masterStoryIds);
|
|
12
|
+
return model.stories.filter((story) => !masterStories.has(story.id)).sort((left, right) => (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(right.id) ?? Number.MAX_SAFE_INTEGER));
|
|
13
|
+
}
|
|
14
|
+
function modelToBook(model) {
|
|
15
|
+
const preferences = model.preferences;
|
|
16
|
+
const rightBinding = preferences.binding === "right-to-left";
|
|
17
|
+
const chapters = orderedStories(model).map((story) => ({
|
|
18
|
+
title: story.id,
|
|
19
|
+
html: emptyParagraph()
|
|
20
|
+
}));
|
|
21
|
+
if (chapters.length === 0) throw refusal("the IDML has no stories", "add at least one Story");
|
|
22
|
+
const html = chapters.map((chapter) => chapter.html).join("");
|
|
23
|
+
const active = findActiveContent(html);
|
|
24
|
+
if (active.length > 0) throw refusal(`the imported Book contains active content (${active.join(", ")})`, "remove that active content");
|
|
25
|
+
return Book.parse({
|
|
26
|
+
title: model.id,
|
|
27
|
+
mode: "paginated",
|
|
28
|
+
page: {
|
|
29
|
+
paper: {
|
|
30
|
+
width: preferences.pageWidth,
|
|
31
|
+
height: preferences.pageHeight,
|
|
32
|
+
unit: "pt"
|
|
33
|
+
},
|
|
34
|
+
margin: {
|
|
35
|
+
top: preferences.margin.top * MM_PER_PT,
|
|
36
|
+
bottom: preferences.margin.bottom * MM_PER_PT,
|
|
37
|
+
inner: (rightBinding ? preferences.margin.right : preferences.margin.left) * MM_PER_PT,
|
|
38
|
+
outer: (rightBinding ? preferences.margin.left : preferences.margin.right) * MM_PER_PT
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
dir: rightBinding ? "rtl" : "ltr",
|
|
42
|
+
rectoOpenings: preferences.facingPages,
|
|
43
|
+
chapters
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { modelToBook };
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/model.d.ts
|
|
2
|
+
export interface IdmlMargin {
|
|
3
|
+
top: number;
|
|
4
|
+
bottom: number;
|
|
5
|
+
left: number;
|
|
6
|
+
right: number;
|
|
7
|
+
}
|
|
8
|
+
export interface IdmlDocumentPreferences {
|
|
9
|
+
pageWidth: number;
|
|
10
|
+
pageHeight: number;
|
|
11
|
+
facingPages: boolean;
|
|
12
|
+
binding: 'left-to-right' | 'right-to-left';
|
|
13
|
+
margin: IdmlMargin;
|
|
14
|
+
}
|
|
15
|
+
export interface IdmlPage {
|
|
16
|
+
id: string;
|
|
17
|
+
appliedMaster?: string;
|
|
18
|
+
stories: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface IdmlSpread {
|
|
21
|
+
id: string;
|
|
22
|
+
pages: IdmlPage[];
|
|
23
|
+
}
|
|
24
|
+
export interface IdmlMasterSpread {
|
|
25
|
+
id: string;
|
|
26
|
+
pages: IdmlPage[];
|
|
27
|
+
}
|
|
28
|
+
export interface IdmlStory {
|
|
29
|
+
id: string;
|
|
30
|
+
part: string;
|
|
31
|
+
}
|
|
32
|
+
export interface IdmlModel {
|
|
33
|
+
id: string;
|
|
34
|
+
preferences: IdmlDocumentPreferences;
|
|
35
|
+
spreads: IdmlSpread[];
|
|
36
|
+
masterSpreads: IdmlMasterSpread[];
|
|
37
|
+
stories: IdmlStory[];
|
|
38
|
+
masterStoryIds: string[];
|
|
39
|
+
partsRead: string[];
|
|
40
|
+
unsupportedElements: Record<string, number>;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { refusal } from "./refusal.js";
|
|
2
|
+
import { IDML_LIMITS } from "./limits.js";
|
|
3
|
+
import { isTag } from "domhandler";
|
|
4
|
+
import { Parser, parseDocument } from "htmlparser2";
|
|
5
|
+
//#region src/model.ts
|
|
6
|
+
const MAPPED_ELEMENTS = /* @__PURE__ */ new Set([
|
|
7
|
+
"Document",
|
|
8
|
+
"DocumentPreference",
|
|
9
|
+
"MarginPreference",
|
|
10
|
+
"MasterSpread",
|
|
11
|
+
"Spread",
|
|
12
|
+
"Page",
|
|
13
|
+
"Story",
|
|
14
|
+
"TextFrame"
|
|
15
|
+
]);
|
|
16
|
+
function localName(name) {
|
|
17
|
+
return name.includes(":") ? name.split(":").at(-1) ?? name : name;
|
|
18
|
+
}
|
|
19
|
+
function elements(root, name) {
|
|
20
|
+
const found = [];
|
|
21
|
+
const pending = [root];
|
|
22
|
+
while (pending.length > 0) {
|
|
23
|
+
const node = pending.pop();
|
|
24
|
+
if (!node) continue;
|
|
25
|
+
if (isTag(node)) {
|
|
26
|
+
if (!name || localName(node.name) === name) found.push(node);
|
|
27
|
+
}
|
|
28
|
+
if ("children" in node) for (let index = node.children.length - 1; index >= 0; index -= 1) pending.push(node.children[index]);
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
function attribute(element, ...names) {
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const value = element.attribs[name];
|
|
35
|
+
if (value !== void 0) return value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function numberAttribute(element, name, fallback) {
|
|
39
|
+
const raw = element && attribute(element, name);
|
|
40
|
+
const value = raw === void 0 ? fallback : Number(raw);
|
|
41
|
+
if (value === void 0 || !Number.isFinite(value) || value < 0) throw refusal(`IDML ${name} must be a non-negative number`, `set ${name} to a non-negative numeric value`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function boolAttribute(element, name, fallback) {
|
|
45
|
+
const raw = attribute(element, name);
|
|
46
|
+
if (raw === void 0) return fallback;
|
|
47
|
+
if (raw === "true") return true;
|
|
48
|
+
if (raw === "false") return false;
|
|
49
|
+
throw refusal(`IDML ${name} must be true or false`, `set ${name} to true or false`);
|
|
50
|
+
}
|
|
51
|
+
function checkXmlDepth(source, part, limit) {
|
|
52
|
+
let depth = 0;
|
|
53
|
+
new Parser({
|
|
54
|
+
onopentag() {
|
|
55
|
+
depth += 1;
|
|
56
|
+
if (depth > limit) throw refusal(`IDML XML part ${part} exceeds the ${limit} level XML depth limit`, `reduce its nesting to ${limit} levels or fewer`);
|
|
57
|
+
},
|
|
58
|
+
onclosetag() {
|
|
59
|
+
depth -= 1;
|
|
60
|
+
}
|
|
61
|
+
}, {
|
|
62
|
+
xmlMode: true,
|
|
63
|
+
recognizeSelfClosing: true
|
|
64
|
+
}).end(source);
|
|
65
|
+
}
|
|
66
|
+
function parseParts(entries, limits) {
|
|
67
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
68
|
+
return entries.filter((entry) => entry.name.toLowerCase().endsWith(".xml")).map((entry) => {
|
|
69
|
+
let source;
|
|
70
|
+
try {
|
|
71
|
+
source = decoder.decode(entry.bytes);
|
|
72
|
+
} catch {
|
|
73
|
+
throw refusal(`IDML XML part ${entry.name} is not valid UTF-8`, "export that XML part as UTF-8");
|
|
74
|
+
}
|
|
75
|
+
let document;
|
|
76
|
+
try {
|
|
77
|
+
checkXmlDepth(source, entry.name, limits.xmlDepth);
|
|
78
|
+
document = parseDocument(source, {
|
|
79
|
+
xmlMode: true,
|
|
80
|
+
recognizeSelfClosing: true
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof Error && error.message.includes("XML depth limit")) throw error;
|
|
84
|
+
throw refusal(`IDML XML part ${entry.name} could not be parsed: ${String(error)}`, "export well-formed XML");
|
|
85
|
+
}
|
|
86
|
+
const all = elements(document);
|
|
87
|
+
if (all.length === 0) throw refusal(`IDML XML part ${entry.name} has no root element`, "export a non-empty well-formed XML part");
|
|
88
|
+
return {
|
|
89
|
+
name: entry.name,
|
|
90
|
+
document,
|
|
91
|
+
all
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function page(element) {
|
|
96
|
+
const id = attribute(element, "Self");
|
|
97
|
+
if (!id) throw refusal("an IDML Page has no Self identifier", "give every Page a Self identifier");
|
|
98
|
+
return {
|
|
99
|
+
id,
|
|
100
|
+
appliedMaster: attribute(element, "AppliedMaster"),
|
|
101
|
+
stories: []
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const IDENTITY = [
|
|
105
|
+
1,
|
|
106
|
+
0,
|
|
107
|
+
0,
|
|
108
|
+
1,
|
|
109
|
+
0,
|
|
110
|
+
0
|
|
111
|
+
];
|
|
112
|
+
function transform(element) {
|
|
113
|
+
const values = (attribute(element, "ItemTransform") ?? "").trim().split(/\s+/).map(Number);
|
|
114
|
+
return values.length === 6 && values.every(Number.isFinite) ? values : IDENTITY;
|
|
115
|
+
}
|
|
116
|
+
function compose(parent, child) {
|
|
117
|
+
const [a, b, c, d, x, y] = parent;
|
|
118
|
+
const [e, f, g, h, u, v] = child;
|
|
119
|
+
return [
|
|
120
|
+
a * e + c * f,
|
|
121
|
+
b * e + d * f,
|
|
122
|
+
a * g + c * h,
|
|
123
|
+
b * g + d * h,
|
|
124
|
+
a * u + c * v + x,
|
|
125
|
+
b * u + d * v + y
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
function apply(value, point) {
|
|
129
|
+
return [value[0] * point[0] + value[2] * point[1] + value[4], value[1] * point[0] + value[3] * point[1] + value[5]];
|
|
130
|
+
}
|
|
131
|
+
function pageArea(element) {
|
|
132
|
+
const bounds = (attribute(element, "GeometricBounds") ?? "").trim().split(/\s+/).map(Number);
|
|
133
|
+
if (bounds.length !== 4 || !bounds.every(Number.isFinite)) return void 0;
|
|
134
|
+
const [top, left, bottom, right] = bounds;
|
|
135
|
+
const matrix = transform(element);
|
|
136
|
+
return [
|
|
137
|
+
apply(matrix, [left, top]),
|
|
138
|
+
apply(matrix, [right, top]),
|
|
139
|
+
apply(matrix, [right, bottom]),
|
|
140
|
+
apply(matrix, [left, bottom])
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
function frameCenter(element, matrix) {
|
|
144
|
+
const anchors = elements(element).map((candidate) => attribute(candidate, "Anchor")).filter((value) => value !== void 0).map((value) => value.trim().split(/\s+/).map(Number)).filter((value) => value.length === 2 && value.every(Number.isFinite)).map((point) => apply(matrix, point));
|
|
145
|
+
if (anchors.length === 0) return void 0;
|
|
146
|
+
const xs = anchors.map(([x]) => x);
|
|
147
|
+
const ys = anchors.map(([, y]) => y);
|
|
148
|
+
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
|
|
149
|
+
}
|
|
150
|
+
function contains(area, [x, y]) {
|
|
151
|
+
let inside = false;
|
|
152
|
+
for (let index = 0, previous = area.length - 1; index < area.length; previous = index, index += 1) {
|
|
153
|
+
const [xi, yi] = area[index];
|
|
154
|
+
const [xj, yj] = area[previous];
|
|
155
|
+
if (yi === yj && y === yi && x >= Math.min(xi, xj) && x <= Math.max(xi, xj)) return true;
|
|
156
|
+
if (yi > y !== yj > y && x <= (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
|
|
157
|
+
}
|
|
158
|
+
return inside;
|
|
159
|
+
}
|
|
160
|
+
function spreadFrames(spread) {
|
|
161
|
+
const result = [];
|
|
162
|
+
const pending = [...spread.children].reverse().map((node) => ({
|
|
163
|
+
node,
|
|
164
|
+
matrix: IDENTITY
|
|
165
|
+
}));
|
|
166
|
+
while (pending.length > 0) {
|
|
167
|
+
const current = pending.pop();
|
|
168
|
+
if (!current || !isTag(current.node)) continue;
|
|
169
|
+
const name = localName(current.node.name);
|
|
170
|
+
if (name === "Page" || name === "Spread" || name === "MasterSpread") continue;
|
|
171
|
+
const matrix = name === "Group" || name === "TextFrame" ? compose(current.matrix, transform(current.node)) : current.matrix;
|
|
172
|
+
if (name === "TextFrame") {
|
|
173
|
+
const story = attribute(current.node, "ParentStory");
|
|
174
|
+
if (story) result.push({
|
|
175
|
+
story,
|
|
176
|
+
center: frameCenter(current.node, matrix)
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
for (let index = current.node.children.length - 1; index >= 0; index -= 1) pending.push({
|
|
181
|
+
node: current.node.children[index],
|
|
182
|
+
matrix
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
function placeStories(element, pageElements, pages, unsupportedCounts) {
|
|
188
|
+
const areas = pageElements.map(pageArea);
|
|
189
|
+
const frameStories = /* @__PURE__ */ new Set();
|
|
190
|
+
for (const frame of spreadFrames(element)) {
|
|
191
|
+
frameStories.add(frame.story);
|
|
192
|
+
const pageIndex = frame.center ? areas.findIndex((area) => area !== void 0 && contains(area, frame.center)) : -1;
|
|
193
|
+
const target = pages[pageIndex];
|
|
194
|
+
if (target && !target.stories.includes(frame.story)) target.stories.push(frame.story);
|
|
195
|
+
else if (pageIndex < 0) unsupportedCounts.set("TextFrame off page", (unsupportedCounts.get("TextFrame off page") ?? 0) + 1);
|
|
196
|
+
}
|
|
197
|
+
return frameStories;
|
|
198
|
+
}
|
|
199
|
+
function spreads(parts, kind, orderedNames, unsupportedCounts) {
|
|
200
|
+
const result = [];
|
|
201
|
+
const frameStories = /* @__PURE__ */ new Set();
|
|
202
|
+
const byName = new Map(parts.map((part) => [part.name, part]));
|
|
203
|
+
const named = new Set(orderedNames);
|
|
204
|
+
const unlisted = parts.filter((part) => !named.has(part.name) && part.all.some((element) => localName(element.name) === kind && attribute(element, "Self"))).length;
|
|
205
|
+
if (unlisted > 0) unsupportedCounts.set("unlisted spread part", (unsupportedCounts.get("unlisted spread part") ?? 0) + unlisted);
|
|
206
|
+
for (const name of orderedNames) {
|
|
207
|
+
const part = byName.get(name);
|
|
208
|
+
if (!part) throw refusal(`designmap.xml ${kind} src ${name} has no matching ZIP entry`, `add the named ${kind} part to the package`);
|
|
209
|
+
for (const element of part.all.filter((candidate) => localName(candidate.name) === kind)) {
|
|
210
|
+
const id = attribute(element, "Self");
|
|
211
|
+
if (!id) continue;
|
|
212
|
+
const pageElements = elements(element, "Page");
|
|
213
|
+
const pages = pageElements.map(page);
|
|
214
|
+
for (const story of placeStories(element, pageElements, pages, unsupportedCounts)) frameStories.add(story);
|
|
215
|
+
result.push({
|
|
216
|
+
id,
|
|
217
|
+
pages
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
values: result,
|
|
223
|
+
frameStories
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function spreadSources(document, kind) {
|
|
227
|
+
return document.all.filter((element) => localName(element.name) === kind).map((element) => attribute(element, "src")).filter((source) => source !== void 0);
|
|
228
|
+
}
|
|
229
|
+
function stories(parts) {
|
|
230
|
+
const result = [];
|
|
231
|
+
const seen = /* @__PURE__ */ new Set();
|
|
232
|
+
for (const part of parts) for (const element of part.all.filter((candidate) => localName(candidate.name) === "Story")) {
|
|
233
|
+
const id = attribute(element, "Self");
|
|
234
|
+
if (!id || seen.has(id)) continue;
|
|
235
|
+
seen.add(id);
|
|
236
|
+
result.push({
|
|
237
|
+
id,
|
|
238
|
+
part: part.name
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
function unsupported(parts) {
|
|
244
|
+
const counts = /* @__PURE__ */ new Map();
|
|
245
|
+
for (const part of parts) for (const element of part.all) {
|
|
246
|
+
const name = localName(element.name);
|
|
247
|
+
if (MAPPED_ELEMENTS.has(name)) continue;
|
|
248
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
249
|
+
}
|
|
250
|
+
return counts;
|
|
251
|
+
}
|
|
252
|
+
function idmlModel(entries, limits = IDML_LIMITS) {
|
|
253
|
+
const parts = parseParts(entries, limits);
|
|
254
|
+
const designmap = parts.find((part) => part.name === "designmap.xml");
|
|
255
|
+
if (!designmap) throw refusal("the IDML designmap.xml entry is missing", "add the required designmap.xml entry");
|
|
256
|
+
const document = parts.flatMap((part) => part.all).find((element) => localName(element.name) === "Document");
|
|
257
|
+
const preference = parts.flatMap((part) => part.all).find((element) => localName(element.name) === "DocumentPreference");
|
|
258
|
+
if (!document) throw refusal("designmap.xml has no IDML Document element", "export a valid IDML design map");
|
|
259
|
+
if (!preference) throw refusal("IDML DocumentPreference is missing", "export document preferences with the package");
|
|
260
|
+
const margin = parts.flatMap((part) => part.all).find((element) => localName(element.name) === "MarginPreference");
|
|
261
|
+
const binding = attribute(preference, "PageBinding") ?? attribute(document, "PageBinding") ?? "LeftToRight";
|
|
262
|
+
if (binding !== "LeftToRight" && binding !== "RightToLeft") throw refusal(`IDML PageBinding ${binding} is not supported`, "use LeftToRight or RightToLeft page binding");
|
|
263
|
+
const unsupportedCounts = unsupported(parts);
|
|
264
|
+
const spreadResult = spreads(parts, "Spread", spreadSources(designmap, "Spread"), unsupportedCounts);
|
|
265
|
+
const masterResult = spreads(parts, "MasterSpread", spreadSources(designmap, "MasterSpread"), unsupportedCounts);
|
|
266
|
+
const masterStoryIds = [...masterResult.frameStories].filter((story) => !spreadResult.frameStories.has(story));
|
|
267
|
+
if (masterStoryIds.length > 0) unsupportedCounts.set("master story", masterStoryIds.length);
|
|
268
|
+
return {
|
|
269
|
+
id: attribute(document, "Self") ?? "IDML document",
|
|
270
|
+
preferences: {
|
|
271
|
+
pageWidth: numberAttribute(preference, "PageWidth"),
|
|
272
|
+
pageHeight: numberAttribute(preference, "PageHeight"),
|
|
273
|
+
facingPages: boolAttribute(preference, "FacingPages", true),
|
|
274
|
+
binding: binding === "RightToLeft" ? "right-to-left" : "left-to-right",
|
|
275
|
+
margin: {
|
|
276
|
+
top: numberAttribute(margin, "Top", 0),
|
|
277
|
+
bottom: numberAttribute(margin, "Bottom", 0),
|
|
278
|
+
left: numberAttribute(margin, "Left", 0),
|
|
279
|
+
right: numberAttribute(margin, "Right", 0)
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
spreads: spreadResult.values,
|
|
283
|
+
masterSpreads: masterResult.values,
|
|
284
|
+
stories: stories(parts),
|
|
285
|
+
masterStoryIds,
|
|
286
|
+
partsRead: parts.map((part) => part.name),
|
|
287
|
+
unsupportedElements: Object.fromEntries([...unsupportedCounts].sort(([left], [right]) => left.localeCompare(right)))
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
export { idmlModel };
|
package/dist/refusal.js
ADDED
package/dist/zip.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { refusal } from "./refusal.js";
|
|
2
|
+
import { IDML_LIMITS } from "./limits.js";
|
|
3
|
+
//#region src/zip.ts
|
|
4
|
+
const END_SIGNATURE = 101010256;
|
|
5
|
+
const CENTRAL_SIGNATURE = 33639248;
|
|
6
|
+
const LOCAL_SIGNATURE = 67324752;
|
|
7
|
+
const MAX_END_SEARCH = 65557;
|
|
8
|
+
function range(view, offset, length, label) {
|
|
9
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || offset + length > view.byteLength) throw refusal(`the ZIP ${label} is outside the package`, "export a complete IDML package");
|
|
10
|
+
}
|
|
11
|
+
function findEnd(view) {
|
|
12
|
+
const first = Math.max(0, view.byteLength - MAX_END_SEARCH);
|
|
13
|
+
for (let offset = view.byteLength - 22; offset >= first; offset -= 1) if (view.getUint32(offset, true) === END_SIGNATURE) return offset;
|
|
14
|
+
throw refusal("the ZIP end-of-central-directory record is missing", "export a complete IDML package");
|
|
15
|
+
}
|
|
16
|
+
function validateCentralEntry(entry, startDisk, names) {
|
|
17
|
+
if ((entry.flags & 65) !== 0) throw refusal(`ZIP entry ${entry.name} is encrypted`, "export the IDML without ZIP encryption");
|
|
18
|
+
if (startDisk !== 0) throw refusal(`ZIP entry ${entry.name} spans disks`, "place every entry in one ordinary ZIP disk");
|
|
19
|
+
if (entry.compressedSize === 4294967295 || entry.inflatedSize === 4294967295 || entry.localOffset === 4294967295) throw refusal(`ZIP entry ${entry.name} uses ZIP64`, "export an ordinary ZIP package within the named IDML limits");
|
|
20
|
+
if (entry.method !== 0 && entry.method !== 8) throw refusal(`ZIP entry ${entry.name} uses compression method ${entry.method}`, "store it or use the deflate compression method");
|
|
21
|
+
if (names.has(entry.name)) throw refusal(`the ZIP contains duplicate entry ${entry.name}`, "give every IDML part one unique package path");
|
|
22
|
+
names.add(entry.name);
|
|
23
|
+
}
|
|
24
|
+
function centralEntries(view, limits) {
|
|
25
|
+
const end = findEnd(view);
|
|
26
|
+
range(view, end, 22, "end-of-central-directory record");
|
|
27
|
+
const disk = view.getUint16(end + 4, true);
|
|
28
|
+
const centralDisk = view.getUint16(end + 6, true);
|
|
29
|
+
const diskEntries = view.getUint16(end + 8, true);
|
|
30
|
+
const entries = view.getUint16(end + 10, true);
|
|
31
|
+
const centralSize = view.getUint32(end + 12, true);
|
|
32
|
+
const centralOffset = view.getUint32(end + 16, true);
|
|
33
|
+
range(view, end, 22 + view.getUint16(end + 20, true), "end-of-central-directory record");
|
|
34
|
+
if (disk !== 0 || centralDisk !== 0 || diskEntries !== entries) throw refusal("ZIP entries spanning disks are not supported", "place every entry in one ordinary ZIP disk");
|
|
35
|
+
if (entries === 65535 || centralSize === 4294967295 || centralOffset === 4294967295) throw refusal("ZIP64 is not supported", "export an ordinary ZIP package within the named IDML limits");
|
|
36
|
+
if (entries > limits.entries) throw refusal(`the ZIP entry count ${entries} exceeds the ${limits.entries} entry limit`, `reduce the package to ${limits.entries} entries or fewer`);
|
|
37
|
+
range(view, centralOffset, centralSize, "central directory");
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
const result = [];
|
|
40
|
+
const names = /* @__PURE__ */ new Set();
|
|
41
|
+
let offset = centralOffset;
|
|
42
|
+
for (let index = 0; index < entries; index += 1) {
|
|
43
|
+
range(view, offset, 46, "central-directory entry");
|
|
44
|
+
if (view.getUint32(offset, true) !== CENTRAL_SIGNATURE) throw refusal(`ZIP central-directory entry ${index + 1} is invalid`, "export a complete IDML package");
|
|
45
|
+
const flags = view.getUint16(offset + 8, true);
|
|
46
|
+
const method = view.getUint16(offset + 10, true);
|
|
47
|
+
const compressedSize = view.getUint32(offset + 20, true);
|
|
48
|
+
const inflatedSize = view.getUint32(offset + 24, true);
|
|
49
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
50
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
51
|
+
const entryCommentLength = view.getUint16(offset + 32, true);
|
|
52
|
+
const startDisk = view.getUint16(offset + 34, true);
|
|
53
|
+
const localOffset = view.getUint32(offset + 42, true);
|
|
54
|
+
const recordLength = 46 + nameLength + extraLength + entryCommentLength;
|
|
55
|
+
range(view, offset, recordLength, "central-directory entry");
|
|
56
|
+
const entry = {
|
|
57
|
+
name: decoder.decode(new Uint8Array(view.buffer, view.byteOffset + offset + 46, nameLength)),
|
|
58
|
+
flags,
|
|
59
|
+
method,
|
|
60
|
+
compressedSize,
|
|
61
|
+
inflatedSize,
|
|
62
|
+
localOffset
|
|
63
|
+
};
|
|
64
|
+
validateCentralEntry(entry, startDisk, names);
|
|
65
|
+
result.push(entry);
|
|
66
|
+
offset += recordLength;
|
|
67
|
+
}
|
|
68
|
+
if (offset !== centralOffset + centralSize) throw refusal("the ZIP central-directory size does not match its entries", "export a complete IDML package");
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
async function inflatedStream(bytes, entry) {
|
|
72
|
+
const source = new Blob([bytes.slice().buffer]).stream();
|
|
73
|
+
if (entry.method === 0) return source;
|
|
74
|
+
if (typeof DecompressionStream === "undefined") throw refusal("DecompressionStream is unavailable at run time", "use a runtime with DecompressionStream deflate-raw support or store every ZIP entry");
|
|
75
|
+
try {
|
|
76
|
+
const decompressor = new DecompressionStream("deflate-raw");
|
|
77
|
+
return source.pipeThrough(decompressor);
|
|
78
|
+
} catch {
|
|
79
|
+
throw refusal("DecompressionStream does not support deflate-raw at run time", "use a runtime with DecompressionStream deflate-raw support or store every ZIP entry");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function inflate(bytes, entry, limits, total) {
|
|
83
|
+
if (entry.inflatedSize > limits.partBytes) throw refusal(`ZIP entry ${entry.name} declares ${entry.inflatedSize} inflated bytes, exceeding the ${limits.partBytes} byte per-part limit`, `reduce that part to ${limits.partBytes} bytes or fewer`);
|
|
84
|
+
const reader = (await inflatedStream(bytes, entry)).getReader();
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let size = 0;
|
|
87
|
+
while (true) {
|
|
88
|
+
let next;
|
|
89
|
+
try {
|
|
90
|
+
next = await reader.read();
|
|
91
|
+
} catch {
|
|
92
|
+
throw refusal(`ZIP entry ${entry.name} could not be inflated`, "export a complete IDML package");
|
|
93
|
+
}
|
|
94
|
+
if (next.done) break;
|
|
95
|
+
size += next.value.byteLength;
|
|
96
|
+
total.bytes += next.value.byteLength;
|
|
97
|
+
if (size > limits.partBytes) {
|
|
98
|
+
await reader.cancel();
|
|
99
|
+
throw refusal(`ZIP entry ${entry.name} exceeds the ${limits.partBytes} byte inflated per-part limit`, `reduce that part to ${limits.partBytes} bytes or fewer`);
|
|
100
|
+
}
|
|
101
|
+
if (total.bytes > limits.totalInflatedBytes) {
|
|
102
|
+
await reader.cancel();
|
|
103
|
+
throw refusal(`the ZIP exceeds the ${limits.totalInflatedBytes} byte total inflated limit`, `reduce all inflated parts to ${limits.totalInflatedBytes} bytes or fewer`);
|
|
104
|
+
}
|
|
105
|
+
chunks.push(next.value);
|
|
106
|
+
}
|
|
107
|
+
if (size !== entry.inflatedSize) throw refusal(`ZIP entry ${entry.name} declared ${entry.inflatedSize} inflated bytes but produced ${size}`, "export a complete IDML package");
|
|
108
|
+
const result = new Uint8Array(size);
|
|
109
|
+
let offset = 0;
|
|
110
|
+
for (const chunk of chunks) {
|
|
111
|
+
result.set(chunk, offset);
|
|
112
|
+
offset += chunk.byteLength;
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
function compressedBytes(bytes, view, entry) {
|
|
117
|
+
range(view, entry.localOffset, 30, `local header for ${entry.name}`);
|
|
118
|
+
if (view.getUint32(entry.localOffset, true) !== LOCAL_SIGNATURE) throw refusal(`the ZIP local header for ${entry.name} is invalid`, "export a complete IDML package");
|
|
119
|
+
const localFlags = view.getUint16(entry.localOffset + 6, true);
|
|
120
|
+
const localMethod = view.getUint16(entry.localOffset + 8, true);
|
|
121
|
+
if ((localFlags & 65) !== 0) throw refusal(`ZIP entry ${entry.name} is encrypted`, "export the IDML without ZIP encryption");
|
|
122
|
+
if (localMethod !== entry.method) throw refusal(`ZIP entry ${entry.name} has conflicting compression methods`, "export a complete IDML package");
|
|
123
|
+
const nameLength = view.getUint16(entry.localOffset + 26, true);
|
|
124
|
+
const extraLength = view.getUint16(entry.localOffset + 28, true);
|
|
125
|
+
const start = entry.localOffset + 30 + nameLength + extraLength;
|
|
126
|
+
range(view, start, entry.compressedSize, `data for ${entry.name}`);
|
|
127
|
+
return bytes.subarray(start, start + entry.compressedSize);
|
|
128
|
+
}
|
|
129
|
+
async function readZip(bytes, limits = IDML_LIMITS) {
|
|
130
|
+
if (bytes.byteLength > limits.packageBytes) throw refusal(`the IDML package has ${bytes.byteLength} bytes, exceeding the ${limits.packageBytes} byte package limit`, `reduce the package to ${limits.packageBytes} bytes or fewer`);
|
|
131
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
132
|
+
const entries = centralEntries(view, limits);
|
|
133
|
+
const result = [];
|
|
134
|
+
const total = { bytes: 0 };
|
|
135
|
+
for (const entry of entries) result.push({
|
|
136
|
+
name: entry.name,
|
|
137
|
+
bytes: await inflate(compressedBytes(bytes, view, entry), entry, limits, total)
|
|
138
|
+
});
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
export { readZip };
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segnavia/idml",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.28.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/modstudio/segnavia.git",
|
|
9
|
+
"directory": "packages/idml"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"@segnavia/source": "./src/index.ts",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@segnavia/format": "0.28.0",
|
|
30
|
+
"@segnavia/html": "0.28.0",
|
|
31
|
+
"dom-serializer": "^2.0.0",
|
|
32
|
+
"domhandler": "^5.0.3",
|
|
33
|
+
"htmlparser2": "^10.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/README.md
DELETED