@canwork/boxwood 1.0.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 Canwork Studios
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/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # Boxwood
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@canwork/boxwood?style=for-the-badge)](https://www.npmjs.com/package/@canwork/boxwood) [![GitHub Actions](https://img.shields.io/github/actions/workflow/status/canwork/boxwood/ci.yml?branch=main&style=for-the-badge)](https://github.com/canwork/boxwood/actions/workflows/ci.yml) [![License](https://img.shields.io/npm/l/@canwork/boxwood?style=for-the-badge)](LICENSE)
4
+
5
+ A Declarative Box-Split and Collision Engine for Vector Graphics. It provides a CSS-like layout grammar (`padding`, `gap`, `cols`, `rows`, `auto-height`) and resolves it into a clean JSON coordinate tree for any visual canvas (SVG, `<canvas>`, WebGL).
6
+
7
+ Unlike typical layout libraries (like Yoga or standard web browsers) that hide resolved position values inside a black box, **Boxwood** exposes absolute coordinate geometry (`{x, y, w, h}`) directly. That makes it easy to draw responsive visuals, route connectors, and manage collision-safe compositions.
8
+
9
+ ---
10
+ ![Demo Animation](./brutalist_playground_visual_1782343082910.webp)
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ npm install @canwork/boxwood
16
+ ```
17
+
18
+ ```ts
19
+ import { resolveLayout, defaultMeasure, LNode } from "@canwork/boxwood";
20
+
21
+ const layoutTree: LNode = {
22
+ style: { padding: 20 },
23
+ split: { cols: ["60%", "40%"], gap: 15 },
24
+ children: [
25
+ { id: "main-panel", style: { w: "100%", h: "100%" } },
26
+ {
27
+ style: { w: "100%", h: "100%" },
28
+ split: { rows: 2, gap: 10 },
29
+ children: [
30
+ { id: "sub-card-1", style: { padding: 12 } },
31
+ { id: "sub-card-2", style: { padding: 12 } },
32
+ ],
33
+ },
34
+ ],
35
+ };
36
+
37
+ const boundary = { x: 0, y: 0, w: 1920, h: 1080 };
38
+ const result = resolveLayout(layoutTree, boundary, { measure: defaultMeasure });
39
+ console.log(result.boxes);
40
+ ```
41
+
42
+ ### CDN (no build step)
43
+
44
+ ```html
45
+ <script src="https://cdn.jsdelivr.net/npm/@canwork/boxwood/dist/boxwood.global.js"></script>
46
+ <script>
47
+ const { resolveLayout, defaultMeasure } = Boxwood;
48
+ const result = resolveLayout(layoutTree, boundary, { measure: defaultMeasure });
49
+ </script>
50
+ ```
51
+
52
+ Or via unpkg:
53
+
54
+ ```html
55
+ <script src="https://unpkg.com/@canwork/boxwood/dist/boxwood.global.js"></script>
56
+ ```
57
+
58
+ ## Layout flow at a glance
59
+
60
+ ```mermaid
61
+ flowchart TD
62
+ A["LNode tree\nstyle / split / children"] --> B["resolveLayout(root, boundary, opts)"]
63
+ B --> C["Resolve layout rules\npadding, gap, cols, rows, grid"]
64
+ B --> D["Measure content\ntext wrap / overflow"]
65
+ C --> E["Compute contentFrame and box geometry"]
66
+ D --> E
67
+ E --> F["Resolved boxes\n{x, y, w, h}"]
68
+ F --> G["Render with SVG / Canvas / WebGL"]
69
+ ```
70
+
71
+ ```mermaid
72
+ flowchart TB
73
+ R["root LNode"] --> C1["child LNode"]
74
+ R --> C2["child LNode"]
75
+ C1 --> G1["grandchild LNode"]
76
+ C2 --> G2["grandchild LNode"]
77
+ C1 --> G3["grandchild LNode"]
78
+ C2 --> G4["grandchild LNode"]
79
+ G1 --> B1["resolved box"]
80
+ G2 --> B2["resolved box"]
81
+ G3 --> B3["resolved box"]
82
+ G4 --> B4["resolved box"]
83
+ ```
84
+
85
+ ## Why Boxwood?
86
+
87
+ Boxwood is designed for developers who need a declarative layout model but also need the exact coordinates to render shapes, arrows, labels, and collision-aware content.
88
+
89
+ | Layout Library | Declarative Box Model | Coordinates for Lines/Arrows | Lightweight & Zero-DOM | Collision Separation |
90
+ | :--- | :---: | :---: | :---: | :---: |
91
+ | **Yoga / Flexbox** | ✅ Yes | ❌ No | ⚠️ Complex C++ bindings | ❌ No |
92
+ | **D3 / Cytoscape** | ❌ No | ✅ Yes | ✅ Yes | ⚠️ Force-directed |
93
+ | **Boxwood** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
94
+
95
+ ### What Boxwood gives you
96
+
97
+ - Declarative layout rules for cols, rows, grids, gap, padding, margin, and auto sizing
98
+ - Absolute coordinate output ready for SVG, `<canvas>`, WebGL, or custom rendering
99
+ - Nested layout composition with resolved geometry for every node
100
+ - Text measurement and overflow-aware sizing via `measure`
101
+ - Collision resolution so layout boxes do not overlap unexpectedly
102
+
103
+ ---
104
+
105
+ ## Demo
106
+
107
+ Open `examples/interactive-demo.html` in your browser to see the engine running with a live resize demo.
108
+
109
+ ---
110
+
111
+ ## Core API & Types
112
+
113
+ ### 1. Defining a Layout Tree (`LNode`)
114
+
115
+ Layouts are represented as a nested tree structure:
116
+
117
+ ```typescript
118
+ import { LNode } from "@canwork/boxwood";
119
+
120
+ const layoutTree: LNode = {
121
+ style: { padding: 20 },
122
+ split: { cols: ["60%", "40%"], gap: 15 },
123
+ children: [
124
+ {
125
+ id: "main-panel",
126
+ style: { w: "100%", h: "100%" }
127
+ },
128
+ {
129
+ style: { w: "100%", h: "100%" },
130
+ split: { rows: 2, gap: 10 },
131
+ children: [
132
+ { id: "sub-card-1", style: { padding: 12 } },
133
+ { id: "sub-card-2", style: { padding: 12 } }
134
+ ]
135
+ }
136
+ ]
137
+ };
138
+ ```
139
+
140
+ ### 2. Resolving Layout Coordinates (`resolveLayout`)
141
+
142
+ Run the solver on your layout tree by passing the root node and the parent boundary dimensions:
143
+
144
+ ```typescript
145
+ import { resolveLayout } from "@canwork/boxwood";
146
+
147
+ const boundary = { x: 0, y: 0, w: 1920, h: 1080 }; // 1080p frame
148
+ const result = resolveLayout(layoutTree, boundary);
149
+
150
+ console.log(result.boxes);
151
+ /*
152
+ Outputs a flat list of absolute coordinate boxes:
153
+ [
154
+ { id: "main-panel", box: { x: 20, y: 20, w: 1128, h: 1040 } },
155
+ { id: "sub-card-1", box: { x: 1163, y: 20, w: 737, h: 515 } },
156
+ { id: "sub-card-2", box: { x: 1163, y: 545, w: 737, h: 515 } }
157
+ ]
158
+ */
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Drawing Layout Connections
164
+
165
+ Because **Boxwood** returns absolute coordinate values, routing arrows and lines between elements is simple:
166
+
167
+ ```typescript
168
+ // Find the resolved boxes
169
+ const boxA = result.boxes.find(b => b.node.id === "sub-card-1")?.box;
170
+ const boxB = result.boxes.find(b => b.node.id === "sub-card-2")?.box;
171
+
172
+ if (boxA && boxB) {
173
+ // Center coordinates of both boxes
174
+ const x1 = boxA.x + boxA.w / 2;
175
+ const y1 = boxA.y + boxA.h / 2;
176
+ const x2 = boxB.x + boxB.w / 2;
177
+ const y2 = boxB.y + boxB.h / 2;
178
+
179
+ // Render SVG or Canvas lines using these points
180
+ // e.g. <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="gold" />
181
+ }
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Advanced Features
187
+
188
+ ### 1. Dynamic Text Measurements & Font Sizing
189
+ You can pass custom measurement rules to child nodes using a `measure` hook. The engine will automatically evaluate if text fits inside a container. If text wraps or overflows, it raises a structured `OverflowSignal` in the solver result, letting you decrease font size or handle resizing programmatically.
190
+
191
+ ### 2. Collision Separation Solver
192
+ When visual cards enter with scale spring animations, or dynamic data changes their sizes on the fly, Boxwood runs an overlap solver to push boxes apart, guaranteeing your nodes never overlap.
193
+
194
+ ---
195
+
196
+ ## Visual Examples & Use Cases
197
+
198
+ To showcase the responsiveness and design rules of the layout solver, we built a **Google Stitch Stark Brutalist Playground** in the `examples/` directory:
199
+
200
+ * **[interactive-demo.html](file:///Users/bonzai-carn/Documents/CanworkStudios/content-generator/storyboard/layout-engine/examples/interactive-demo.html)**: Open this file directly in any browser. It renders an interactive viewport with a corner drag-handle, running the layout solver in real-time as you scale or squeeze the container.
201
+
202
+ Additionally, we have prepared self-contained console scripts demonstrating each use case:
203
+
204
+ 1. **`01-bento-grid.ts`**: Builds a dashboard-style "Bento box" grid with side-by-side splits and shows how it adapts dynamically between desktop and mobile portrait layout shapes.
205
+ 2. **`02-vector-connections.ts`**: Demonstrates horizontal pipeline layout structures, retrieving coordinate boxes, and generating Bezier path coordinates to draw custom SVG connection arrows.
206
+ 3. **`03-absolute-to-responsive.ts`**: Illustrates how to take a set of static, absolute coordinate elements (e.g. from PDF.js) and wrap them into a responsive Boxwood column grid.
207
+ 4. **`04-dynamic-reflow-overflow.ts`**: Walks through the dynamic text measurement system, showing how text wraps and shifts downstream sibling cards downwards to prevent overlap, alongside emitting overflow alerts.
208
+
209
+ ---
210
+
211
+ ## License
212
+
213
+ MIT © Canwork Studios
@@ -0,0 +1 @@
1
+ "use strict";var Boxwood=(()=>{var A=Object.defineProperty;var G=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var O=Object.prototype.hasOwnProperty;var X=(e,t)=>{for(var n in t)A(e,n,{get:t[n],enumerable:!0})},Y=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of I(t))!O.call(e,s)&&s!==n&&A(e,s,{get:()=>t[s],enumerable:!(o=G(t,s))||o.enumerable});return e};var Z=e=>Y(A({},"__esModule",{value:!0}),e);var re={};X(re,{defaultMeasure:()=>B,resolveColWidths:()=>w,resolveCollisions:()=>N,resolveGap:()=>L,resolveLayout:()=>j,resolveRowHeights:()=>M,resolveSides:()=>d,shrinkToFit:()=>E,splitCols:()=>g,splitGrid:()=>R,splitRows:()=>S,splitZones:()=>v});function d(e){return e===void 0?{top:0,right:0,bottom:0,left:0}:typeof e=="number"?{top:e,right:e,bottom:e,left:e}:{top:e.top??0,right:e.right??0,bottom:e.bottom??0,left:e.left??0}}function K(e,t,n){if(e===void 0||e==="auto")return n;if(typeof e=="number")return e;let o=parseFloat(e);return e.endsWith("%")&&!isNaN(o)?o/100*t:n}function L(e,t){return K(e,t,0)}function w(e,t,n){if(typeof e=="number"){let r=Math.max(1,e),h=(t-n*(r-1))/r;return Array.from({length:r},(c,x)=>({w:h,kind:"fixed",idx:x}))}let o=0,s=0,i=0;for(let r of e)typeof r=="number"?o+=r:r==="auto"?i++:r.endsWith("%")&&(s+=parseFloat(r));let l=s/100*t,f=t-o-l-n*(e.length-1),u=i>0?Math.max(0,f/i):0;return e.map((r,h)=>{if(typeof r=="number")return{w:r,kind:"fixed",idx:h};if(r==="auto")return{w:u,kind:"auto",idx:h};let c=parseFloat(r);return r.endsWith("%")&&!isNaN(c)?{w:c/100*t,kind:"pct",idx:h}:{w:0,kind:"fixed",idx:h}})}function g(e,t,n,o){let s=e.x+o.left,i=e.y+o.top,l=e.w-o.left-o.right,f=e.h-o.top-o.bottom,u=[],r=s;for(let h of t)u.push({x:r,y:i,w:h.w,h:f}),r+=h.w+n;return u}function M(e,t,n,o){if(typeof e=="number"){let c=Math.max(1,e),x=(t-n*(c-1))/c;return Array.from({length:c},(m,b)=>({h:x,kind:"fixed",idx:b}))}let s=e.length,i=0,l=0,f=e.filter(c=>c==="auto").length;for(let c=0;c<s;c++){let x=e[c];if(typeof x=="number")i+=x;else if(typeof x=="string"&&x.endsWith("%")){let m=parseFloat(x);isNaN(m)||(l+=m/100*t)}}let u=t-i-l-n*(s-1),r=o.reduce((c,x)=>c+x,0),h=0;return f>0&&(r<=u?h=r/f:h=u/f),e.map((c,x)=>{if(typeof c=="number")return{h:c,kind:"fixed",idx:x};if(c==="auto")return{h,kind:"auto",idx:x};let m=parseFloat(c);return c.endsWith("%")&&!isNaN(m)?{h:m/100*t,kind:"pct",idx:x}:{h:0,kind:"fixed",idx:x}})}function S(e,t,n,o){let s=e.x+o.left,i=e.y+o.top,l=e.w-o.left-o.right,f=e.h-o.top-o.bottom,u=[],r=i;for(let h of t)u.push({x:s,y:r,w:l,h:h.h}),r+=h.h+n;return u}function R(e,t,n,o){let[s,i]=t,l=e.x+o.left,f=e.y+o.top,u=e.w-o.left-o.right,r=e.h-o.top-o.bottom,h=(u-n*(s-1))/s,c=(r-n*(i-1))/i,x=[];for(let m=0;m<i;m++)for(let b=0;b<s;b++)x.push({x:l+b*(h+n),y:f+m*(c+n),w:h,h:c});return x}function v(e){return Object.values(e)}function k(e,t,n){if(t<=0)return e;let o=e;for(;o>14&&n(o)>t;)o-=1;return Math.max(14,o)}function W(e,t,n){if(t<=0||!e)return[e];let o=e.split(/\s+/),s=[],i="";for(let l of o){let f=i?i+" "+l:l;n(f)<=t||!i?i=f:(s.push(i),i=l)}return i&&s.push(i),s}var T=8;function q(e,t){return e.length*t*(T/14)}function B(e,t,n){let o=k(t.fontSize,n,h=>q(e,h)),s=o*(T/14),i=h=>h.length*s,l=W(e,n,i),f=o*1.2,u=o*.8,r=o*.2;return{lines:l,w:Math.min(n,Math.max(...l.map(i))),h:l.length*f,ascent:u,descent:r}}function E(e,t,n,o,s,i=14){let l=t;for(;l>=i;){let u=s(e,{fontSize:l},n);if(u.h<=o)return{fontSize:l,lines:u.lines,h:u.h};l-=1}let f=s(e,{fontSize:i},n);return{fontSize:i,lines:f.lines,h:f.h}}function H(e,t){let n=t.parent;for(;n;){if(n===e)return!0;n=n.parent}return!1}function J(e,t){return e.x<t.x+t.w&&e.x+e.w>t.x&&e.y<t.y+t.h&&e.y+e.h>t.y}function Q(e,t){let n=Math.max(0,Math.min(e.x+e.w,t.x+t.w)-Math.max(e.x,t.x)),o=Math.max(0,Math.min(e.y+e.h,t.y+t.h)-Math.max(e.y,t.y));return n*o}var P=10,$=5;function N(e,t){if(!(e.length<2)){for(let n=0;n<$;n++){let o=!1;for(let s=0;s<e.length;s++)for(let i=s+1;i<e.length;i++){let l=e[s],f=e[i];if(H(l,f)||H(f,l)||!J(l.box,f.box))continue;o=!0;let u=l.box,r=f.box,h=Q(u,r),c=Math.min(u.x+u.w,r.x+r.w)-Math.max(u.x,r.x),x=Math.min(u.y+u.h,r.y+r.h)-Math.max(u.y,r.y),m=c<x?P:0,b=c>=x?P:0;m!==0&&(u.x<r.x?r.x+=m:u.x+=m),b!==0&&(u.y<r.y?r.y+=b:u.y+=b)}if(!o)break}for(let n of e)n.box.x=Math.max(0,Math.min(n.box.x,t.w-n.box.w)),n.box.y=Math.max(0,Math.min(n.box.y,t.h-n.box.h))}}function V(e,t){return{x:e.x+t.left,y:e.y+t.top,w:Math.max(0,e.w-t.left-t.right),h:Math.max(0,e.h-t.top-t.bottom)}}function F(e,t){return{x:e.x+t.left,y:e.y+t.top,w:Math.max(0,e.w-t.left-t.right),h:Math.max(0,e.h-t.top-t.bottom)}}function C(e,t,n){if(e===void 0||e==="auto")return n;if(typeof e=="number")return e;let o=parseFloat(e);return e.endsWith("%")&&!isNaN(o)?o/100*t:n}function ee(e,t,n,o){return e==="center"?{x:t.x+(t.w-n)/2,y:t.y+(t.h-o)/2}:e===void 0?{x:t.x,y:t.y}:{x:e.x!==void 0?C(e.x,t.w,t.x):t.x,y:e.y!==void 0?C(e.y,t.h,t.y):t.y}}function _(e,t){if(t.push(e),e.children)for(let n of e.children)_(n,t)}function te(e,t){if(e===void 0||e==="auto")return{h:0,isAuto:!0};if(typeof e=="number")return{h:e,isAuto:!1};if(typeof e=="string"&&e.endsWith("%")){let n=parseFloat(e);if(!isNaN(n))return{h:n/100*t,isAuto:!1}}return{h:0,isAuto:!0}}function y(e,t,n,o,s){let i=d(e.style.margin),l=d(e.style.padding),f,u,r,h,c=te(e.style.h,t.h);if(n)f=n.w,u=n.h!==0?n.h:c.h,r=n.x,h=n.y;else{f=C(e.style.w,t.w,t.w),u=c.h;let a=ee(e.style.pos,t,f,u||t.h);r=a.x,h=a.y}let x=V({x:r,y:h,w:f,h:u},i),m=F(x,l);u===0&&!e.measure&&(x.h=t.h,m.h=Math.max(0,t.h-l.top-l.bottom));let b,p;if(e.split?b=ne(e,e.split,m,o,s):e.children&&e.children.length>0&&(b=e.children.map(a=>y(a,m,null,o,s))),e.measure&&m.w>0){let a=e.measure(m);if(p={lines:a.lines,fontSize:a.ascent?Math.round(a.ascent/.8):14,ascent:a.ascent,descent:a.descent},u===0){let D=a.h+l.top+l.bottom;x.h=Math.max(D,1),m.h=Math.max(a.h,1)}a.h>m.h&&m.h>0&&s.push({path:"",box:m,fontSize:p.fontSize,text:a.lines.join(" ")})}let z={node:e,box:{x:x.x,y:x.y,w:x.w,h:x.h},contentFrame:{x:m.x,y:m.y,w:m.w,h:m.h},textLayout:p,children:b};if(b)for(let a of b)a.parent=z;return z}function ne(e,t,n,o,s){let i=e.children;if(!i||i.length===0)return[];let l="gap"in t&&t.gap!==void 0?L(t.gap,n.w):0,f="pad"in t&&t.pad!==void 0?d(t.pad):d(void 0);if("rows"in t&&(typeof t.rows=="number"||Array.isArray(t.rows))){let u=n.w-f.left-f.right,r=[];for(let m=0;m<i.length;m++)r.push(oe(i[m],u,o.measure));let h=n.h-f.top-f.bottom,c=M(t.rows,h,l,r),x=S(n,c,l,f);return i.map((m,b)=>{let p=x[Math.min(b,x.length-1)];return y(m,n,p,o,s)})}if("cols"in t&&(typeof t.cols=="number"||Array.isArray(t.cols))){let u=n.w-f.left-f.right,r=w(t.cols,u,l),h=g(n,r,l,f);return i.map((c,x)=>{let m=h[Math.min(x,h.length-1)];return y(c,n,m,o,s)})}if("grid"in t){let u=R(n,t.grid,l,f);return i.map((r,h)=>{let c=u[Math.min(h,u.length-1)];return y(r,n,c,o,s)})}if("zones"in t){let u=v(t.zones);return i.map((r,h)=>{let c=u[Math.min(h,u.length-1)];return y(r,n,c,o,s)})}return[]}function oe(e,t,n){if(!e.measure)return e.children&&e.children.length>0?60:0;let o={x:0,y:0,w:t,h:0},s=e.measure(o),i=d(e.style.padding);return s.h+i.top+i.bottom}function j(e,t,n){let o=n?.measure??B,s=[],i=y(e,t,null,{measure:o},s),l=[];return _(i,l),N(l,t),{root:i,boxes:l,overflow:s}}return Z(re);})();
@@ -0,0 +1,2 @@
1
+ import type { LBox, ResolvedLNode } from "./types.js";
2
+ export declare function resolveCollisions(resolvedNodes: ResolvedLNode[], world: LBox): void;
@@ -0,0 +1,62 @@
1
+ function isAncestor(ancestor, node) {
2
+ let cur = node.parent;
3
+ while (cur) {
4
+ if (cur === ancestor)
5
+ return true;
6
+ cur = cur.parent;
7
+ }
8
+ return false;
9
+ }
10
+ function boxesOverlap(a, b) {
11
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
12
+ }
13
+ function overlapArea(a, b) {
14
+ const ox = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
15
+ const oy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
16
+ return ox * oy;
17
+ }
18
+ const NUDGE_DISTANCE = 10;
19
+ const MAX_PASSES = 5;
20
+ export function resolveCollisions(resolvedNodes, world) {
21
+ if (resolvedNodes.length < 2)
22
+ return;
23
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
24
+ let anyCollision = false;
25
+ for (let i = 0; i < resolvedNodes.length; i++) {
26
+ for (let j = i + 1; j < resolvedNodes.length; j++) {
27
+ const ni = resolvedNodes[i];
28
+ const nj = resolvedNodes[j];
29
+ if (isAncestor(ni, nj) || isAncestor(nj, ni))
30
+ continue;
31
+ if (!boxesOverlap(ni.box, nj.box))
32
+ continue;
33
+ anyCollision = true;
34
+ const a = ni.box;
35
+ const b = nj.box;
36
+ const area = overlapArea(a, b);
37
+ const overlapW = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
38
+ const overlapH = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
39
+ const nudgeX = overlapW < overlapH ? NUDGE_DISTANCE : 0;
40
+ const nudgeY = overlapW >= overlapH ? NUDGE_DISTANCE : 0;
41
+ if (nudgeX !== 0) {
42
+ if (a.x < b.x)
43
+ b.x += nudgeX;
44
+ else
45
+ a.x += nudgeX;
46
+ }
47
+ if (nudgeY !== 0) {
48
+ if (a.y < b.y)
49
+ b.y += nudgeY;
50
+ else
51
+ a.y += nudgeY;
52
+ }
53
+ }
54
+ }
55
+ if (!anyCollision)
56
+ break;
57
+ }
58
+ for (const rn of resolvedNodes) {
59
+ rn.box.x = Math.max(0, Math.min(rn.box.x, world.w - rn.box.w));
60
+ rn.box.y = Math.max(0, Math.min(rn.box.y, world.h - rn.box.h));
61
+ }
62
+ }
@@ -0,0 +1,5 @@
1
+ export type { LBox, LStyle, LNode, Len, Sides, Pos, Split, ResolvedLNode, TextLayout, Measure, LayoutResult, OverflowSignal, } from "./types.js";
2
+ export { resolveLayout } from "./resolve.js";
3
+ export { defaultMeasure, shrinkToFit } from "./measure.js";
4
+ export { resolveSides, resolveGap, resolveColWidths, resolveRowHeights, splitCols, splitRows, splitGrid, splitZones, } from "./split.js";
5
+ export { resolveCollisions } from "./collide.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { resolveLayout } from "./resolve.js";
2
+ export { defaultMeasure, shrinkToFit } from "./measure.js";
3
+ export { resolveSides, resolveGap, resolveColWidths, resolveRowHeights, splitCols, splitRows, splitGrid, splitZones, } from "./split.js";
4
+ export { resolveCollisions } from "./collide.js";
@@ -0,0 +1,15 @@
1
+ import type { Measure } from "./types.js";
2
+ export declare function defaultMeasure(text: string, style: {
3
+ fontSize: number;
4
+ }, maxW: number): {
5
+ lines: string[];
6
+ w: number;
7
+ h: number;
8
+ ascent: number;
9
+ descent: number;
10
+ };
11
+ export declare function shrinkToFit(text: string, fontSize: number, maxW: number, maxH: number, measurePage: Measure, floor?: number): {
12
+ fontSize: number;
13
+ lines: string[];
14
+ h: number;
15
+ };
@@ -0,0 +1,33 @@
1
+ import { wrapText, fitFontSize, MIN_READABLE } from "./text-fit.js";
2
+ const FALLBACK_CHAR_WIDTH = 8;
3
+ function estimateTextWidth(text, fontSize) {
4
+ return text.length * fontSize * (FALLBACK_CHAR_WIDTH / 14);
5
+ }
6
+ export function defaultMeasure(text, style, maxW) {
7
+ const size = fitFontSize(style.fontSize, maxW, (s) => estimateTextWidth(text, s));
8
+ const charW = size * (FALLBACK_CHAR_WIDTH / 14);
9
+ const measureText = (t) => t.length * charW;
10
+ const lines = wrapText(text, maxW, measureText);
11
+ const lineH = size * 1.2;
12
+ const ascent = size * 0.8;
13
+ const descent = size * 0.2;
14
+ return {
15
+ lines,
16
+ w: Math.min(maxW, Math.max(...lines.map(measureText))),
17
+ h: lines.length * lineH,
18
+ ascent,
19
+ descent,
20
+ };
21
+ }
22
+ export function shrinkToFit(text, fontSize, maxW, maxH, measurePage, floor = MIN_READABLE) {
23
+ let size = fontSize;
24
+ while (size >= floor) {
25
+ const m = measurePage(text, { fontSize: size }, maxW);
26
+ if (m.h <= maxH) {
27
+ return { fontSize: size, lines: m.lines, h: m.h };
28
+ }
29
+ size -= 1;
30
+ }
31
+ const m = measurePage(text, { fontSize: floor }, maxW);
32
+ return { fontSize: floor, lines: m.lines, h: m.h };
33
+ }
@@ -0,0 +1,4 @@
1
+ import type { LBox, LNode, Measure, LayoutResult } from "./types.js";
2
+ export declare function resolveLayout(root: LNode, world: LBox, hooks?: {
3
+ measure?: Measure;
4
+ }): LayoutResult;
@@ -0,0 +1,205 @@
1
+ import { resolveSides, resolveGap, resolveColWidths, resolveRowHeights, splitCols, splitRows, splitGrid, splitZones, } from "./split.js";
2
+ import { defaultMeasure } from "./measure.js";
3
+ import { resolveCollisions } from "./collide.js";
4
+ function applyMargin(box, margin) {
5
+ return {
6
+ x: box.x + margin.left,
7
+ y: box.y + margin.top,
8
+ w: Math.max(0, box.w - margin.left - margin.right),
9
+ h: Math.max(0, box.h - margin.top - margin.bottom),
10
+ };
11
+ }
12
+ function applyPad(box, pad) {
13
+ return {
14
+ x: box.x + pad.left,
15
+ y: box.y + pad.top,
16
+ w: Math.max(0, box.w - pad.left - pad.right),
17
+ h: Math.max(0, box.h - pad.top - pad.bottom),
18
+ };
19
+ }
20
+ function resolveLen(v, parentSize, defaultVal) {
21
+ if (v === undefined)
22
+ return defaultVal;
23
+ if (v === "auto")
24
+ return defaultVal;
25
+ if (typeof v === "number")
26
+ return v;
27
+ const pct = parseFloat(v);
28
+ if (v.endsWith("%") && !isNaN(pct))
29
+ return (pct / 100) * parentSize;
30
+ return defaultVal;
31
+ }
32
+ function resolvePos(pos, parentFrame, nodeW, nodeH) {
33
+ if (pos === "center") {
34
+ return {
35
+ x: parentFrame.x + (parentFrame.w - nodeW) / 2,
36
+ y: parentFrame.y + (parentFrame.h - nodeH) / 2,
37
+ };
38
+ }
39
+ if (pos === undefined) {
40
+ return { x: parentFrame.x, y: parentFrame.y };
41
+ }
42
+ return {
43
+ x: pos.x !== undefined ? resolveLen(pos.x, parentFrame.w, parentFrame.x) : parentFrame.x,
44
+ y: pos.y !== undefined ? resolveLen(pos.y, parentFrame.h, parentFrame.y) : parentFrame.y,
45
+ };
46
+ }
47
+ function flattenResolved(node, out) {
48
+ out.push(node);
49
+ if (node.children) {
50
+ for (const c of node.children)
51
+ flattenResolved(c, out);
52
+ }
53
+ }
54
+ function resolveBoxHeight(styleH, parentH) {
55
+ if (styleH === undefined || styleH === "auto") {
56
+ return { h: 0, isAuto: true };
57
+ }
58
+ if (typeof styleH === "number") {
59
+ return { h: styleH, isAuto: false };
60
+ }
61
+ if (typeof styleH === "string" && styleH.endsWith("%")) {
62
+ const pct = parseFloat(styleH);
63
+ if (!isNaN(pct))
64
+ return { h: (pct / 100) * parentH, isAuto: false };
65
+ }
66
+ return { h: 0, isAuto: true };
67
+ }
68
+ function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
69
+ const margin = resolveSides(node.style.margin);
70
+ const pad = resolveSides(node.style.padding);
71
+ let boxW;
72
+ let boxH;
73
+ let boxX;
74
+ let boxY;
75
+ const hRes = resolveBoxHeight(node.style.h, parentFrame.h);
76
+ if (slotBox) {
77
+ boxW = slotBox.w;
78
+ boxH = slotBox.h !== 0 ? slotBox.h : hRes.h;
79
+ boxX = slotBox.x;
80
+ boxY = slotBox.y;
81
+ }
82
+ else {
83
+ boxW = resolveLen(node.style.w, parentFrame.w, parentFrame.w);
84
+ boxH = hRes.h;
85
+ const posRes = resolvePos(node.style.pos, parentFrame, boxW, boxH || parentFrame.h);
86
+ boxX = posRes.x;
87
+ boxY = posRes.y;
88
+ }
89
+ let margined = applyMargin({ x: boxX, y: boxY, w: boxW, h: boxH }, margin);
90
+ let contentFrame = applyPad(margined, pad);
91
+ // Fix container auto-height BEFORE resolving children — children need a real
92
+ // content frame for row distribution.
93
+ if (boxH === 0 && !node.measure) {
94
+ margined.h = parentFrame.h;
95
+ contentFrame.h = Math.max(0, parentFrame.h - pad.top - pad.bottom);
96
+ }
97
+ let children;
98
+ let textLayout;
99
+ if (node.split) {
100
+ children = resolveSplitChildren(node, node.split, contentFrame, ctx, overflow);
101
+ }
102
+ else if (node.children && node.children.length > 0) {
103
+ children = node.children.map((child) => resolveNode(child, contentFrame, null, ctx, overflow));
104
+ }
105
+ if (node.measure && contentFrame.w > 0) {
106
+ const measured = node.measure(contentFrame);
107
+ textLayout = {
108
+ lines: measured.lines,
109
+ fontSize: measured.ascent ? Math.round(measured.ascent / 0.8) : 14,
110
+ ascent: measured.ascent,
111
+ descent: measured.descent,
112
+ };
113
+ if (boxH === 0) {
114
+ const totalH = measured.h + pad.top + pad.bottom;
115
+ margined.h = Math.max(totalH, 1);
116
+ contentFrame.h = Math.max(measured.h, 1);
117
+ }
118
+ if (measured.h > contentFrame.h && contentFrame.h > 0) {
119
+ overflow.push({
120
+ path: "",
121
+ box: contentFrame,
122
+ fontSize: textLayout.fontSize,
123
+ text: measured.lines.join(" "),
124
+ });
125
+ }
126
+ }
127
+ const resolved = {
128
+ node,
129
+ box: { x: margined.x, y: margined.y, w: margined.w, h: margined.h },
130
+ contentFrame: { x: contentFrame.x, y: contentFrame.y, w: contentFrame.w, h: contentFrame.h },
131
+ textLayout,
132
+ children,
133
+ };
134
+ if (children) {
135
+ for (const c of children)
136
+ c.parent = resolved;
137
+ }
138
+ return resolved;
139
+ }
140
+ function resolveSplitChildren(node, split, contentFrame, ctx, overflow) {
141
+ const ch = node.children;
142
+ if (!ch || ch.length === 0)
143
+ return [];
144
+ const gap = "gap" in split && split.gap !== undefined ? resolveGap(split.gap, contentFrame.w) : 0;
145
+ const sp = "pad" in split && split.pad !== undefined ? resolveSides(split.pad) : resolveSides(undefined);
146
+ if ("rows" in split && (typeof split.rows === "number" || Array.isArray(split.rows))) {
147
+ const innerW = contentFrame.w - sp.left - sp.right;
148
+ const intrinsicHs = [];
149
+ for (let i = 0; i < ch.length; i++) {
150
+ intrinsicHs.push(estimateIntrinsicHeight(ch[i], innerW, ctx.measure));
151
+ }
152
+ const innerH = contentFrame.h - sp.top - sp.bottom;
153
+ const rowSpecs = resolveRowHeights(split.rows, innerH, gap, intrinsicHs);
154
+ const cells = splitRows(contentFrame, rowSpecs, gap, sp);
155
+ return ch.map((child, i) => {
156
+ const cell = cells[Math.min(i, cells.length - 1)];
157
+ return resolveNode(child, contentFrame, cell, ctx, overflow);
158
+ });
159
+ }
160
+ if ("cols" in split && (typeof split.cols === "number" || Array.isArray(split.cols))) {
161
+ const innerW = contentFrame.w - sp.left - sp.right;
162
+ const colSpecs = resolveColWidths(split.cols, innerW, gap);
163
+ const cells = splitCols(contentFrame, colSpecs, gap, sp);
164
+ return ch.map((child, i) => {
165
+ const cell = cells[Math.min(i, cells.length - 1)];
166
+ return resolveNode(child, contentFrame, cell, ctx, overflow);
167
+ });
168
+ }
169
+ if ("grid" in split) {
170
+ const cells = splitGrid(contentFrame, split.grid, gap, sp);
171
+ return ch.map((child, i) => {
172
+ const cell = cells[Math.min(i, cells.length - 1)];
173
+ return resolveNode(child, contentFrame, cell, ctx, overflow);
174
+ });
175
+ }
176
+ if ("zones" in split) {
177
+ const cells = splitZones(split.zones);
178
+ return ch.map((child, i) => {
179
+ const cell = cells[Math.min(i, cells.length - 1)];
180
+ return resolveNode(child, contentFrame, cell, ctx, overflow);
181
+ });
182
+ }
183
+ return [];
184
+ }
185
+ function estimateIntrinsicHeight(child, innerW, measure) {
186
+ if (!child.measure) {
187
+ if (child.children && child.children.length > 0) {
188
+ return 60; // default min row height for containers
189
+ }
190
+ return 0;
191
+ }
192
+ const avail = { x: 0, y: 0, w: innerW, h: 0 };
193
+ const m = child.measure(avail);
194
+ const pad = resolveSides(child.style.padding);
195
+ return m.h + pad.top + pad.bottom;
196
+ }
197
+ export function resolveLayout(root, world, hooks) {
198
+ const measure = hooks?.measure ?? defaultMeasure;
199
+ const overflow = [];
200
+ const resolved = resolveNode(root, world, null, { measure }, overflow);
201
+ const boxes = [];
202
+ flattenResolved(resolved, boxes);
203
+ resolveCollisions(boxes, world);
204
+ return { root: resolved, boxes, overflow };
205
+ }
@@ -0,0 +1,39 @@
1
+ import type { LBox, Len, Sides } from "./types.js";
2
+ export declare function resolveSides(s: Sides | undefined): {
3
+ top: number;
4
+ right: number;
5
+ bottom: number;
6
+ left: number;
7
+ };
8
+ export declare function resolveGap(gap: Len | undefined, parentSize: number): number;
9
+ export interface ColSpec {
10
+ w: number;
11
+ kind: "fixed" | "pct" | "auto";
12
+ idx: number;
13
+ }
14
+ export interface RowSpec {
15
+ h: number;
16
+ kind: "fixed" | "pct" | "auto";
17
+ idx: number;
18
+ }
19
+ export declare function resolveColWidths(spec: number | Len[], parentW: number, gap: number): ColSpec[];
20
+ export declare function splitCols(parentFrame: LBox, cols: ColSpec[], gap: number, pad: {
21
+ top: number;
22
+ right: number;
23
+ bottom: number;
24
+ left: number;
25
+ }): LBox[];
26
+ export declare function resolveRowHeights(spec: number | Len[], parentH: number, gap: number, childIntrinsicHs: number[]): RowSpec[];
27
+ export declare function splitRows(parentFrame: LBox, rows: RowSpec[], gap: number, pad: {
28
+ top: number;
29
+ right: number;
30
+ bottom: number;
31
+ left: number;
32
+ }): LBox[];
33
+ export declare function splitGrid(parentFrame: LBox, grid: [cols: number, rows: number], gap: number, pad: {
34
+ top: number;
35
+ right: number;
36
+ bottom: number;
37
+ left: number;
38
+ }): LBox[];
39
+ export declare function splitZones(zones: Record<string, LBox>): LBox[];
package/dist/split.js ADDED
@@ -0,0 +1,151 @@
1
+ export function resolveSides(s) {
2
+ if (s === undefined)
3
+ return { top: 0, right: 0, bottom: 0, left: 0 };
4
+ if (typeof s === "number")
5
+ return { top: s, right: s, bottom: s, left: s };
6
+ return { top: s.top ?? 0, right: s.right ?? 0, bottom: s.bottom ?? 0, left: s.left ?? 0 };
7
+ }
8
+ function resolveLen(v, parentSize, defaultVal) {
9
+ if (v === undefined)
10
+ return defaultVal;
11
+ if (v === "auto")
12
+ return defaultVal;
13
+ if (typeof v === "number")
14
+ return v;
15
+ const pct = parseFloat(v);
16
+ if (v.endsWith("%") && !isNaN(pct))
17
+ return (pct / 100) * parentSize;
18
+ return defaultVal;
19
+ }
20
+ export function resolveGap(gap, parentSize) {
21
+ return resolveLen(gap, parentSize, 0);
22
+ }
23
+ export function resolveColWidths(spec, parentW, gap) {
24
+ if (typeof spec === "number") {
25
+ const n = Math.max(1, spec);
26
+ const colW = (parentW - gap * (n - 1)) / n;
27
+ return Array.from({ length: n }, (_, i) => ({ w: colW, kind: "fixed", idx: i }));
28
+ }
29
+ let totalFixed = 0;
30
+ let totalPctPct = 0;
31
+ let autoCount = 0;
32
+ for (const s of spec) {
33
+ if (typeof s === "number") {
34
+ totalFixed += s;
35
+ }
36
+ else if (s === "auto") {
37
+ autoCount++;
38
+ }
39
+ else if (s.endsWith("%")) {
40
+ totalPctPct += parseFloat(s);
41
+ }
42
+ }
43
+ const totalPct = (totalPctPct / 100) * parentW;
44
+ const remaining = parentW - totalFixed - totalPct - gap * (spec.length - 1);
45
+ const autoW = autoCount > 0 ? Math.max(0, remaining / autoCount) : 0;
46
+ return spec.map((s, idx) => {
47
+ if (typeof s === "number")
48
+ return { w: s, kind: "fixed", idx };
49
+ if (s === "auto")
50
+ return { w: autoW, kind: "auto", idx };
51
+ const pct = parseFloat(s);
52
+ if (s.endsWith("%") && !isNaN(pct)) {
53
+ return { w: (pct / 100) * parentW, kind: "pct", idx };
54
+ }
55
+ return { w: 0, kind: "fixed", idx };
56
+ });
57
+ }
58
+ export function splitCols(parentFrame, cols, gap, pad) {
59
+ const innerX = parentFrame.x + pad.left;
60
+ const innerY = parentFrame.y + pad.top;
61
+ const innerW = parentFrame.w - pad.left - pad.right;
62
+ const innerH = parentFrame.h - pad.top - pad.bottom;
63
+ const boxes = [];
64
+ let cx = innerX;
65
+ for (const c of cols) {
66
+ boxes.push({ x: cx, y: innerY, w: c.w, h: innerH });
67
+ cx += c.w + gap;
68
+ }
69
+ return boxes;
70
+ }
71
+ export function resolveRowHeights(spec, parentH, gap, childIntrinsicHs) {
72
+ if (typeof spec === "number") {
73
+ const n = Math.max(1, spec);
74
+ const rowH = (parentH - gap * (n - 1)) / n;
75
+ return Array.from({ length: n }, (_, i) => ({ h: rowH, kind: "fixed", idx: i }));
76
+ }
77
+ const n = spec.length;
78
+ let usedFixed = 0;
79
+ let usedPct = 0;
80
+ const autoCount = spec.filter((s) => s === "auto").length;
81
+ for (let i = 0; i < n; i++) {
82
+ const s = spec[i];
83
+ if (typeof s === "number") {
84
+ usedFixed += s;
85
+ }
86
+ else if (typeof s === "string" && s.endsWith("%")) {
87
+ const pct = parseFloat(s);
88
+ if (!isNaN(pct))
89
+ usedPct += (pct / 100) * parentH;
90
+ }
91
+ }
92
+ const remaining = parentH - usedFixed - usedPct - gap * (n - 1);
93
+ const intrinsicTotal = childIntrinsicHs.reduce((a, b) => a + b, 0);
94
+ let autoH = 0;
95
+ if (autoCount > 0) {
96
+ if (intrinsicTotal <= remaining) {
97
+ autoH = intrinsicTotal / autoCount;
98
+ }
99
+ else {
100
+ autoH = remaining / autoCount;
101
+ }
102
+ }
103
+ return spec.map((s, idx) => {
104
+ if (typeof s === "number")
105
+ return { h: s, kind: "fixed", idx };
106
+ if (s === "auto")
107
+ return { h: autoH, kind: "auto", idx };
108
+ const pct = parseFloat(s);
109
+ if (s.endsWith("%") && !isNaN(pct)) {
110
+ return { h: (pct / 100) * parentH, kind: "pct", idx };
111
+ }
112
+ return { h: 0, kind: "fixed", idx };
113
+ });
114
+ }
115
+ export function splitRows(parentFrame, rows, gap, pad) {
116
+ const innerX = parentFrame.x + pad.left;
117
+ const innerY = parentFrame.y + pad.top;
118
+ const innerW = parentFrame.w - pad.left - pad.right;
119
+ const innerH = parentFrame.h - pad.top - pad.bottom;
120
+ const boxes = [];
121
+ let cy = innerY;
122
+ for (const r of rows) {
123
+ boxes.push({ x: innerX, y: cy, w: innerW, h: r.h });
124
+ cy += r.h + gap;
125
+ }
126
+ return boxes;
127
+ }
128
+ export function splitGrid(parentFrame, grid, gap, pad) {
129
+ const [nCols, nRows] = grid;
130
+ const innerX = parentFrame.x + pad.left;
131
+ const innerY = parentFrame.y + pad.top;
132
+ const innerW = parentFrame.w - pad.left - pad.right;
133
+ const innerH = parentFrame.h - pad.top - pad.bottom;
134
+ const cellW = (innerW - gap * (nCols - 1)) / nCols;
135
+ const cellH = (innerH - gap * (nRows - 1)) / nRows;
136
+ const cells = [];
137
+ for (let r = 0; r < nRows; r++) {
138
+ for (let c = 0; c < nCols; c++) {
139
+ cells.push({
140
+ x: innerX + c * (cellW + gap),
141
+ y: innerY + r * (cellH + gap),
142
+ w: cellW,
143
+ h: cellH,
144
+ });
145
+ }
146
+ }
147
+ return cells;
148
+ }
149
+ export function splitZones(zones) {
150
+ return Object.values(zones);
151
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Self-contained text-fit helpers used by the default measure hook.
3
+ *
4
+ * These are intentionally measurement-agnostic: callers pass a `measure`
5
+ * closure (e.g. over a canvas `ctx.measureText`), so the engine never depends
6
+ * on a DOM or any specific rasterizer.
7
+ */
8
+ /** Hard floor — nothing should render below this. */
9
+ export declare const MIN_READABLE = 14;
10
+ /**
11
+ * Shrink a font size so `text` fits within `maxWidth`, but never below the hard
12
+ * floor. Returns the largest size ≤ `size` that fits, or MIN_READABLE if even
13
+ * that overflows (the caller may then wrap). `measure(sizePx)` returns text
14
+ * width at that size.
15
+ */
16
+ export declare function fitFontSize(size: number, maxWidth: number, measure: (sizePx: number) => number): number;
17
+ /**
18
+ * Greedy word-wrap into lines that each fit `maxWidth`. `measure(text)` returns
19
+ * width at the current font. Long single words are kept whole.
20
+ */
21
+ export declare function wrapText(text: string, maxWidth: number, measure: (text: string) => number): string[];
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Self-contained text-fit helpers used by the default measure hook.
3
+ *
4
+ * These are intentionally measurement-agnostic: callers pass a `measure`
5
+ * closure (e.g. over a canvas `ctx.measureText`), so the engine never depends
6
+ * on a DOM or any specific rasterizer.
7
+ */
8
+ /** Hard floor — nothing should render below this. */
9
+ export const MIN_READABLE = 14;
10
+ /**
11
+ * Shrink a font size so `text` fits within `maxWidth`, but never below the hard
12
+ * floor. Returns the largest size ≤ `size` that fits, or MIN_READABLE if even
13
+ * that overflows (the caller may then wrap). `measure(sizePx)` returns text
14
+ * width at that size.
15
+ */
16
+ export function fitFontSize(size, maxWidth, measure) {
17
+ if (maxWidth <= 0)
18
+ return size;
19
+ let s = size;
20
+ while (s > MIN_READABLE && measure(s) > maxWidth) {
21
+ s -= 1;
22
+ }
23
+ return Math.max(MIN_READABLE, s);
24
+ }
25
+ /**
26
+ * Greedy word-wrap into lines that each fit `maxWidth`. `measure(text)` returns
27
+ * width at the current font. Long single words are kept whole.
28
+ */
29
+ export function wrapText(text, maxWidth, measure) {
30
+ if (maxWidth <= 0 || !text)
31
+ return [text];
32
+ const words = text.split(/\s+/);
33
+ const lines = [];
34
+ let cur = "";
35
+ for (const w of words) {
36
+ const trial = cur ? cur + " " + w : w;
37
+ if (measure(trial) <= maxWidth || !cur) {
38
+ cur = trial;
39
+ }
40
+ else {
41
+ lines.push(cur);
42
+ cur = w;
43
+ }
44
+ }
45
+ if (cur)
46
+ lines.push(cur);
47
+ return lines;
48
+ }
@@ -0,0 +1,88 @@
1
+ export interface LBox {
2
+ x: number;
3
+ y: number;
4
+ w: number;
5
+ h: number;
6
+ }
7
+ export type Sides = number | {
8
+ top?: number;
9
+ right?: number;
10
+ bottom?: number;
11
+ left?: number;
12
+ };
13
+ export type Len = number | `${number}%` | "auto";
14
+ export type Pos = {
15
+ x?: Len;
16
+ y?: Len;
17
+ } | "center";
18
+ export type Split = {
19
+ rows: number | Len[];
20
+ gap?: Len;
21
+ pad?: Sides;
22
+ } | {
23
+ cols: number | Len[];
24
+ gap?: Len;
25
+ pad?: Sides;
26
+ } | {
27
+ grid: [cols: number, rows: number];
28
+ gap?: Len;
29
+ pad?: Sides;
30
+ } | {
31
+ zones: Record<string, LBox>;
32
+ };
33
+ export interface LStyle {
34
+ margin?: Sides;
35
+ padding?: Sides;
36
+ w?: Len;
37
+ h?: Len;
38
+ pos?: Pos;
39
+ }
40
+ export interface LNode {
41
+ id?: string;
42
+ style: LStyle;
43
+ measure?: (avail: LBox) => {
44
+ w: number;
45
+ h: number;
46
+ ascent: number;
47
+ descent: number;
48
+ lines: string[];
49
+ };
50
+ children?: LNode[];
51
+ split?: Split;
52
+ }
53
+ export interface TextLayout {
54
+ lines: string[];
55
+ fontSize: number;
56
+ ascent: number;
57
+ descent: number;
58
+ }
59
+ export interface ResolvedLNode {
60
+ node: LNode;
61
+ box: LBox;
62
+ contentFrame: LBox;
63
+ textLayout?: TextLayout;
64
+ children?: ResolvedLNode[];
65
+ parent?: ResolvedLNode;
66
+ }
67
+ export type Measure = (text: string, style: {
68
+ fontSize: number;
69
+ fontWeight?: number;
70
+ fontFamily?: string;
71
+ }, maxW: number) => {
72
+ lines: string[];
73
+ w: number;
74
+ h: number;
75
+ ascent: number;
76
+ descent: number;
77
+ };
78
+ export interface OverflowSignal {
79
+ path: string;
80
+ box: LBox;
81
+ fontSize: number;
82
+ text: string;
83
+ }
84
+ export interface LayoutResult {
85
+ root: ResolvedLNode;
86
+ boxes: ResolvedLNode[];
87
+ overflow: OverflowSignal[];
88
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@canwork/boxwood",
3
+ "version": "1.0.0",
4
+ "description": "A Declarative Box-Split and Collision Engine for Vector Graphics. It provides a CSS-like layout grammar (padding, gap, cols, rows, auto-height), but outputs a clean JSON coordinate tree that any visual canvas (SVG, <canvas>, WebGL) can use to draw pixels, connectors, and particles.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "unpkg": "dist/boxwood.global.js",
8
+ "jsdelivr": "dist/boxwood.global.js",
9
+ "type": "module",
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "build:bundle": "esbuild index.ts --bundle --minify --format=iife --global-name=Boxwood --outfile=dist/boxwood.global.js",
18
+ "build:all": "npm run build && npm run build:bundle",
19
+ "prepublishOnly": "npm run build:all"
20
+ },
21
+ "keywords": [
22
+ "layout-engine",
23
+ "svg-layout",
24
+ "canvas-layout",
25
+ "vector-graphics",
26
+ "absolute-coordinates",
27
+ "box-model",
28
+ "declarative-layout",
29
+ "remotion"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/carnworkstudios/boxwood.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/carnworkstudios/boxwood/issues"
37
+ },
38
+ "homepage": "https://github.com/carnworkstudios/boxwood#readme",
39
+ "author": "Canwork Studios",
40
+ "license": "MIT",
41
+ "devDependencies": {
42
+ "esbuild": "^0.28.1",
43
+ "typescript": "^5.4.5"
44
+ }
45
+ }