@segnavia/format 0.0.0-stage → 0.2.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 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.
@@ -0,0 +1,6 @@
1
+ //#region src/direction.d.ts
2
+ export type TextDirection = 'ltr' | 'rtl';
3
+ export declare function firstStrongDirection(text: string): TextDirection | null;
4
+ export declare function languageDirection(lang: string | undefined): TextDirection | null;
5
+ export declare function resolveDirection(dir: 'ltr' | 'rtl' | 'auto' | undefined, lang: string | undefined, sample: string): TextDirection;
6
+ //#endregion
@@ -0,0 +1,39 @@
1
+ //#region src/direction.ts
2
+ const RTL_LETTER = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}\p{Script=Mende_Kikakui}\p{Script=Old_Hungarian}\p{Script=Imperial_Aramaic}\p{Script=Phoenician}]/u;
3
+ const LETTER = /\p{L}/u;
4
+ const RTL_LANGUAGES = /* @__PURE__ */ new Set([
5
+ "ar",
6
+ "arc",
7
+ "ckb",
8
+ "dv",
9
+ "fa",
10
+ "he",
11
+ "iw",
12
+ "ji",
13
+ "ks",
14
+ "ku",
15
+ "nqo",
16
+ "ps",
17
+ "sd",
18
+ "syr",
19
+ "ug",
20
+ "ur",
21
+ "yi"
22
+ ]);
23
+ function firstStrongDirection(text) {
24
+ for (const char of text) {
25
+ if (RTL_LETTER.test(char)) return "rtl";
26
+ if (LETTER.test(char)) return "ltr";
27
+ }
28
+ return null;
29
+ }
30
+ function languageDirection(lang) {
31
+ if (!lang) return null;
32
+ return RTL_LANGUAGES.has(lang.toLowerCase().split("-")[0] ?? "") ? "rtl" : "ltr";
33
+ }
34
+ function resolveDirection(dir, lang, sample) {
35
+ if (dir === "ltr" || dir === "rtl") return dir;
36
+ return languageDirection(lang) ?? firstStrongDirection(sample) ?? "ltr";
37
+ }
38
+ //#endregion
39
+ export { firstStrongDirection, languageDirection, resolveDirection };
@@ -0,0 +1,62 @@
1
+ import { PaperBox } from "./paper.js";
2
+ import { Imposition, Page, Print } from "./index.js";
3
+ //#region src/imposition.d.ts
4
+ export interface MillimeterEdges {
5
+ top: number;
6
+ right: number;
7
+ bottom: number;
8
+ left: number;
9
+ }
10
+ export interface Placement {
11
+ page: number;
12
+ paper: number;
13
+ side: 'front' | 'back';
14
+ column: number;
15
+ row: number;
16
+ trim: PaperBox;
17
+ bleed: MillimeterEdges;
18
+ bleedBox: PaperBox;
19
+ safeBox: PaperBox;
20
+ clipped: Array<'top' | 'right' | 'bottom' | 'left'>;
21
+ }
22
+ export interface Imposed {
23
+ paper: {
24
+ width: number;
25
+ height: number;
26
+ };
27
+ papers: number;
28
+ sides: 1 | 2;
29
+ placements: Placement[];
30
+ }
31
+ export interface PageFit {
32
+ flow: boolean;
33
+ width: number;
34
+ height: number | null;
35
+ scale: number;
36
+ x: number;
37
+ y: number;
38
+ bands: {
39
+ inline: number;
40
+ block: number;
41
+ };
42
+ crop: MillimeterEdges;
43
+ differs: boolean;
44
+ }
45
+ export type PagePlacementKind = 'fluid' | 'growing' | 'fixed';
46
+ export interface PagePlacementLayout {
47
+ kind: PagePlacementKind;
48
+ piece: {
49
+ width: number;
50
+ height: number;
51
+ };
52
+ inset: boolean;
53
+ paper: PaperBox;
54
+ }
55
+ export declare function mirrorPlacement(front: Placement, paper: Imposed['paper'], flip: Imposition['flip'], page?: number): Placement;
56
+ export declare function impose(imposition: Imposition, pageCount: number): Imposed;
57
+ export declare function fitPage(page: {
58
+ size: Page['size'];
59
+ print?: Print;
60
+ }, placement: Placement, fit: 'contain' | 'cover'): PageFit;
61
+ export declare function pagePlacementLayout(size: Page['size'], placement: Placement, fit: PageFit): PagePlacementLayout;
62
+ //#endregion
@@ -0,0 +1,294 @@
1
+ import { PX_PER_MM, roundMillimeters, sheetMm, toMillimeters } from "./paper.js";
2
+ //#region src/imposition.ts
3
+ const zeroCrop = () => ({
4
+ top: 0,
5
+ right: 0,
6
+ bottom: 0,
7
+ left: 0
8
+ });
9
+ function placementAt(imposition, paper, cell, page, paperSize) {
10
+ const mm = (value) => toMillimeters(value, imposition.unit);
11
+ const column = cell % imposition.columns;
12
+ const row = Math.floor(cell / imposition.columns);
13
+ const physicalColumn = imposition.dir === "rtl" ? imposition.columns - column - 1 : column;
14
+ const cellWidth = mm(imposition.cell.width);
15
+ const cellHeight = mm(imposition.cell.height);
16
+ const columnGap = mm(imposition.gap.column);
17
+ const rowGap = mm(imposition.gap.row);
18
+ const x = imposition.dir === "rtl" ? paperSize.width - mm(imposition.margin.right) - cellWidth - column * (cellWidth + columnGap) : mm(imposition.margin.left) + column * (cellWidth + columnGap);
19
+ const y = mm(imposition.margin.top) + row * (cellHeight + rowGap);
20
+ const requested = mm(imposition.bleed);
21
+ const bleed = {
22
+ top: Math.min(requested, row > 0 ? rowGap / 2 : y),
23
+ right: Math.min(requested, physicalColumn < imposition.columns - 1 ? columnGap / 2 : Math.max(0, paperSize.width - x - cellWidth)),
24
+ bottom: Math.min(requested, row < imposition.rows - 1 ? rowGap / 2 : Math.max(0, paperSize.height - y - cellHeight)),
25
+ left: Math.min(requested, physicalColumn > 0 ? columnGap / 2 : Math.max(0, x))
26
+ };
27
+ const clipped = [
28
+ "top",
29
+ "right",
30
+ "bottom",
31
+ "left"
32
+ ].filter((side) => bleed[side] < requested);
33
+ const trim = {
34
+ x: roundMillimeters(x),
35
+ y: roundMillimeters(y),
36
+ width: roundMillimeters(cellWidth),
37
+ height: roundMillimeters(cellHeight)
38
+ };
39
+ const roundedBleed = {
40
+ top: roundMillimeters(bleed.top),
41
+ right: roundMillimeters(bleed.right),
42
+ bottom: roundMillimeters(bleed.bottom),
43
+ left: roundMillimeters(bleed.left)
44
+ };
45
+ const safe = mm(imposition.safe);
46
+ return {
47
+ page,
48
+ paper,
49
+ side: "front",
50
+ column,
51
+ row,
52
+ trim,
53
+ bleed: roundedBleed,
54
+ bleedBox: {
55
+ x: roundMillimeters(x - bleed.left),
56
+ y: roundMillimeters(y - bleed.top),
57
+ width: roundMillimeters(cellWidth + bleed.left + bleed.right),
58
+ height: roundMillimeters(cellHeight + bleed.top + bleed.bottom)
59
+ },
60
+ safeBox: {
61
+ x: roundMillimeters(x + safe),
62
+ y: roundMillimeters(y + safe),
63
+ width: roundMillimeters(cellWidth - safe * 2),
64
+ height: roundMillimeters(cellHeight - safe * 2)
65
+ },
66
+ clipped
67
+ };
68
+ }
69
+ const mirrorBox = (box, paper, axis) => axis === "x" ? {
70
+ ...box,
71
+ x: roundMillimeters(paper.width - box.x - box.width)
72
+ } : {
73
+ ...box,
74
+ y: roundMillimeters(paper.height - box.y - box.height)
75
+ };
76
+ const EDGE_ORDER = [
77
+ "top",
78
+ "right",
79
+ "bottom",
80
+ "left"
81
+ ];
82
+ function mirrorEdge(edge, axis) {
83
+ if (axis === "x") return edge === "left" ? "right" : edge === "right" ? "left" : edge;
84
+ return edge === "top" ? "bottom" : edge === "bottom" ? "top" : edge;
85
+ }
86
+ function mirrorEdges(edges, axis) {
87
+ const mirrored = { ...edges };
88
+ for (const edge of EDGE_ORDER) mirrored[mirrorEdge(edge, axis)] = edges[edge];
89
+ return mirrored;
90
+ }
91
+ function mirrorPlacement(front, paper, flip, page = front.page) {
92
+ const portrait = paper.width <= paper.height;
93
+ const axis = flip === "long" === portrait ? "x" : "y";
94
+ const mirroredClipped = new Set(front.clipped.map((edge) => mirrorEdge(edge, axis)));
95
+ return {
96
+ ...front,
97
+ page,
98
+ side: "back",
99
+ trim: mirrorBox(front.trim, paper, axis),
100
+ bleed: mirrorEdges(front.bleed, axis),
101
+ bleedBox: mirrorBox(front.bleedBox, paper, axis),
102
+ safeBox: mirrorBox(front.safeBox, paper, axis),
103
+ clipped: EDGE_ORDER.filter((edge) => mirroredClipped.has(edge))
104
+ };
105
+ }
106
+ function assignPieces(imposition, pieces, papers) {
107
+ const cells = imposition.columns * imposition.rows;
108
+ const assignments = [];
109
+ let piece = 0;
110
+ for (let paperIndex = 0; paperIndex < papers; paperIndex += 1) {
111
+ const firstCell = paperIndex === 0 ? imposition.skip : 0;
112
+ for (let cell = firstCell; cell < cells; cell += 1) {
113
+ const assignedPiece = imposition.fill === "repeat" ? paperIndex : piece;
114
+ if (assignedPiece >= pieces) break;
115
+ assignments.push({
116
+ paper: paperIndex,
117
+ cell,
118
+ piece: assignedPiece
119
+ });
120
+ if (imposition.fill === "sequence") piece += 1;
121
+ }
122
+ }
123
+ return assignments;
124
+ }
125
+ function expandAssignments(imposition, assignments, pageCount, paper) {
126
+ const placements = [];
127
+ let currentPaper = -1;
128
+ let fronts = [];
129
+ let backs = [];
130
+ const flush = () => {
131
+ placements.push(...fronts, ...backs);
132
+ fronts = [];
133
+ backs = [];
134
+ };
135
+ for (const assignment of assignments) {
136
+ if (assignment.paper !== currentPaper) {
137
+ flush();
138
+ currentPaper = assignment.paper;
139
+ }
140
+ const frontPage = imposition.sides === "two" ? assignment.piece * 2 : assignment.piece;
141
+ const front = placementAt(imposition, assignment.paper, assignment.cell, frontPage, paper);
142
+ fronts.push(front);
143
+ if (imposition.sides === "two" && frontPage + 1 < pageCount) backs.push(mirrorPlacement(front, paper, imposition.flip, frontPage + 1));
144
+ }
145
+ flush();
146
+ return placements;
147
+ }
148
+ function impose(imposition, pageCount) {
149
+ if (!Number.isInteger(pageCount) || pageCount < 0) throw new RangeError(`pageCount must be a nonnegative integer; received ${String(pageCount)}`);
150
+ const paper = sheetMm({
151
+ paper: imposition.paper,
152
+ orientation: imposition.orientation
153
+ });
154
+ const sides = imposition.sides === "two" ? 2 : 1;
155
+ if (pageCount === 0) return {
156
+ paper,
157
+ papers: 0,
158
+ sides,
159
+ placements: []
160
+ };
161
+ const cells = imposition.columns * imposition.rows;
162
+ const pieces = imposition.sides === "two" ? Math.ceil(pageCount / 2) : pageCount;
163
+ const papers = imposition.fill === "repeat" ? pieces : Math.ceil((pieces + imposition.skip) / cells);
164
+ return {
165
+ paper,
166
+ papers,
167
+ sides,
168
+ placements: expandAssignments(imposition, assignPieces(imposition, pieces, papers), pageCount, paper)
169
+ };
170
+ }
171
+ function trimDiffers(page, placement) {
172
+ if (!page.print) return false;
173
+ const trim = sheetMm(page.print);
174
+ return Math.abs(trim.width - placement.trim.width) > .01 || Math.abs(trim.height - placement.trim.height) > .01;
175
+ }
176
+ function fitPage(page, placement, fit) {
177
+ const differs = trimDiffers(page, placement);
178
+ if (page.size === "fluid") return {
179
+ flow: true,
180
+ width: placement.bleedBox.width * PX_PER_MM,
181
+ height: null,
182
+ scale: 1,
183
+ x: placement.bleedBox.x,
184
+ y: placement.bleedBox.y,
185
+ bands: {
186
+ inline: 0,
187
+ block: 0
188
+ },
189
+ crop: zeroCrop(),
190
+ differs
191
+ };
192
+ if (page.size.height === "auto") return {
193
+ flow: true,
194
+ width: page.size.width,
195
+ height: null,
196
+ scale: placement.trim.width * PX_PER_MM / page.size.width,
197
+ x: placement.trim.x,
198
+ y: placement.trim.y,
199
+ bands: {
200
+ inline: 0,
201
+ block: 0
202
+ },
203
+ crop: zeroCrop(),
204
+ differs
205
+ };
206
+ if (fit === "contain") {
207
+ const scale = Math.min(placement.trim.width * PX_PER_MM / page.size.width, placement.trim.height * PX_PER_MM / page.size.height);
208
+ const width = page.size.width * scale / PX_PER_MM;
209
+ const height = page.size.height * scale / PX_PER_MM;
210
+ const inline = (placement.trim.width - width) / 2;
211
+ const block = (placement.trim.height - height) / 2;
212
+ return {
213
+ flow: false,
214
+ width: page.size.width,
215
+ height: page.size.height,
216
+ scale,
217
+ x: roundMillimeters(placement.trim.x + inline),
218
+ y: roundMillimeters(placement.trim.y + block),
219
+ bands: {
220
+ inline: roundMillimeters(inline),
221
+ block: roundMillimeters(block)
222
+ },
223
+ crop: zeroCrop(),
224
+ differs
225
+ };
226
+ }
227
+ const horizontalBleed = Math.max(placement.bleed.left, placement.bleed.right);
228
+ const verticalBleed = Math.max(placement.bleed.top, placement.bleed.bottom);
229
+ const scale = Math.max((placement.trim.width + horizontalBleed * 2) * PX_PER_MM / page.size.width, (placement.trim.height + verticalBleed * 2) * PX_PER_MM / page.size.height);
230
+ const width = page.size.width * scale / PX_PER_MM;
231
+ const height = page.size.height * scale / PX_PER_MM;
232
+ const x = placement.trim.x + (placement.trim.width - width) / 2;
233
+ const y = placement.trim.y + (placement.trim.height - height) / 2;
234
+ return {
235
+ flow: false,
236
+ width: page.size.width,
237
+ height: page.size.height,
238
+ scale,
239
+ x: roundMillimeters(x),
240
+ y: roundMillimeters(y),
241
+ bands: {
242
+ inline: 0,
243
+ block: 0
244
+ },
245
+ crop: {
246
+ top: roundMillimeters(Math.max(0, placement.bleedBox.y - y)),
247
+ right: roundMillimeters(Math.max(0, x + width - placement.bleedBox.x - placement.bleedBox.width)),
248
+ bottom: roundMillimeters(Math.max(0, y + height - placement.bleedBox.y - placement.bleedBox.height)),
249
+ left: roundMillimeters(Math.max(0, placement.bleedBox.x - x))
250
+ },
251
+ differs
252
+ };
253
+ }
254
+ function pagePlacementLayout(size, placement, fit) {
255
+ if (size === "fluid") return {
256
+ kind: "fluid",
257
+ piece: {
258
+ width: placement.bleedBox.width * PX_PER_MM,
259
+ height: placement.bleedBox.height * PX_PER_MM
260
+ },
261
+ inset: true,
262
+ paper: placement.bleedBox
263
+ };
264
+ if (size.height === "auto") return {
265
+ kind: "growing",
266
+ piece: {
267
+ width: size.width,
268
+ height: placement.trim.height * PX_PER_MM / fit.scale
269
+ },
270
+ inset: false,
271
+ paper: {
272
+ x: fit.x,
273
+ y: fit.y,
274
+ width: placement.trim.width,
275
+ height: placement.trim.height
276
+ }
277
+ };
278
+ return {
279
+ kind: "fixed",
280
+ piece: {
281
+ width: size.width,
282
+ height: size.height
283
+ },
284
+ inset: false,
285
+ paper: {
286
+ x: fit.x,
287
+ y: fit.y,
288
+ width: size.width * fit.scale / PX_PER_MM,
289
+ height: size.height * fit.scale / PX_PER_MM
290
+ }
291
+ };
292
+ }
293
+ //#endregion
294
+ export { fitPage, impose, mirrorPlacement, pagePlacementLayout };