@unmade/text-renderer 0.1.12 → 0.2.1
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/README.md +115 -0
- package/dist/ce-adapter/index.cjs +1 -1
- package/dist/ce-adapter/index.js +78 -75
- package/dist/index.cjs +5 -5
- package/dist/index.js +2150 -2133
- package/dist/node/index.cjs +5 -5
- package/dist/node/index.js +1099 -1082
- package/dist/worker/index.cjs +15 -15
- package/dist/worker/index.js +2148 -2131
- package/package.json +5 -1
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @unmade/text-renderer
|
|
2
|
+
|
|
3
|
+
Pure function SVG text renderer. No side effects, all dependencies injected explicitly. Web worker compatible.
|
|
4
|
+
|
|
5
|
+
## Public API
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { getRenderedText } from '@unmade/text-renderer';
|
|
9
|
+
|
|
10
|
+
const svg = await getRenderedText({
|
|
11
|
+
text: 'HELLO',
|
|
12
|
+
fontUrl: 'https://example.com/font.ttf',
|
|
13
|
+
physicalSize: [40, 'mm'],
|
|
14
|
+
boxDimensions: {
|
|
15
|
+
width: 500,
|
|
16
|
+
height: 200,
|
|
17
|
+
physicalWidth: 100,
|
|
18
|
+
physicalHeight: 40,
|
|
19
|
+
physicalUnits: 'mm',
|
|
20
|
+
},
|
|
21
|
+
spacing: { outlineWidth: 0, letterSpacing: 0, letterSpacingOutline: 0 },
|
|
22
|
+
baseline: 'flat', // or 'curved', or { type: 'custom', path: '...' }
|
|
23
|
+
horizontalAlignment: 'centre', // 'left' | 'centre' | 'right' | 'distributed'
|
|
24
|
+
verticalAlignment: 'centre', // 'top' | 'centre' | 'bottom'
|
|
25
|
+
fillColour: { hex: '#000000' },
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Import paths
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { getRenderedText } from '@unmade/text-renderer'; // Browser
|
|
33
|
+
import { getRenderedText } from '@unmade/text-renderer/node'; // Node.js
|
|
34
|
+
import { getRenderedText } from '@unmade/text-renderer/worker'; // Web Worker
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration Engine adapter
|
|
38
|
+
|
|
39
|
+
The `ce-adapter` sub-package bridges CE (Configuration Engine) editor state to renderer options:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { extractTextPlacements, mapStateToRendererOptions } from '@unmade/text-renderer/ce-adapter';
|
|
43
|
+
|
|
44
|
+
const placements = extractTextPlacements(editor); // LoadedTextPlacement[]
|
|
45
|
+
const options = mapStateToRendererOptions(placements[0]); // GetRenderedTextOptions
|
|
46
|
+
const svg = await getRenderedText(options);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`extractTextPlacements` reads an editor instance and returns one `LoadedTextPlacement` per text placement, resolving fonts, colours, and position data from the CE state.
|
|
50
|
+
|
|
51
|
+
`mapStateToRendererOptions` converts a `LoadedTextPlacement` into the `GetRenderedTextOptions` shape that `getRenderedText` accepts — including alignment normalisation (CE uses American English, TR uses British English).
|
|
52
|
+
|
|
53
|
+
## Comparison system
|
|
54
|
+
|
|
55
|
+
The `packages/tr-comparison` package provides a pipeline for validating TR output against the CE's own rendering for real production designs.
|
|
56
|
+
|
|
57
|
+
### How it works
|
|
58
|
+
|
|
59
|
+
1. A design URL is POSTed to the comparison Lambda
|
|
60
|
+
2. The Lambda loads the design via the CE factory, extracts all text placements, and renders each one with both CE and TR
|
|
61
|
+
3. Both outputs are rasterised to PNG and diffed pixel-by-pixel with pixelmatch
|
|
62
|
+
4. Results (match score, images, state) are stored in S3 and indexed in DynamoDB
|
|
63
|
+
5. A browser comparison is also run via Puppeteer against the deployed demo app
|
|
64
|
+
6. The comparison dashboard at `packages/tr-comparison/src` displays all results
|
|
65
|
+
|
|
66
|
+
### Stored S3 artefacts
|
|
67
|
+
|
|
68
|
+
For each placement comparison:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
comparisons/{designUrlHash}/{timestamp}/{placementId}/
|
|
72
|
+
ce.png # Config Engine render
|
|
73
|
+
tr.png # Text Renderer render
|
|
74
|
+
diff.png # Pixel difference heatmap
|
|
75
|
+
state.json # LoadedTextPlacement (CE state passed to the adapter)
|
|
76
|
+
renderer-options.json # GetRenderedTextOptions (mapped TR input)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Regression tests
|
|
80
|
+
|
|
81
|
+
Regression tests live in `__tests__/regression.test.ts` and use fixture pairs in `__tests__/fixtures/regression/`:
|
|
82
|
+
|
|
83
|
+
| File | Contents |
|
|
84
|
+
|------|----------|
|
|
85
|
+
| `{name}.json` | Fixture metadata + `LoadedTextPlacement` |
|
|
86
|
+
| `{name}.png` | Reference PNG (expected TR render) |
|
|
87
|
+
|
|
88
|
+
The test pipeline for each fixture:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
LoadedTextPlacement
|
|
92
|
+
→ mapStateToRendererOptions()
|
|
93
|
+
→ getRenderedText()
|
|
94
|
+
→ SVG → PNG (resvg-js)
|
|
95
|
+
→ pixelmatch diff against {name}.png
|
|
96
|
+
→ fail if diff > 1%
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Adding a fixture
|
|
100
|
+
|
|
101
|
+
1. Run a comparison in the dashboard against a design URL
|
|
102
|
+
2. Select the record and click **Save as fixture** in the detail panel
|
|
103
|
+
3. Enter a descriptive name (e.g. `curved-jersey-40mm`) — two files download
|
|
104
|
+
4. Place both files in `__tests__/fixtures/regression/`
|
|
105
|
+
5. Run `npm test` to confirm the fixture passes, then commit
|
|
106
|
+
|
|
107
|
+
### Updating fixtures after an intentional change
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
UPDATE_FIXTURES=true npm test
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This re-renders all fixtures and overwrites the stored PNGs. Review the diffs, then commit.
|
|
114
|
+
|
|
115
|
+
> Regression tests require network access to fetch fonts from their CDN URLs.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function P(t){return t.type==="text"}function w(t){const i=t.getState().garment;return t.settings.garments.find(s=>s.slug===i)}function A(t,l,i){if(!t.embellishment_positions)return;const s=t.embellishment_positions.filter(o=>o.placement!==l?!1:!i||!o.variant?!0:o.variant===i||o.variant.toLowerCase()==="common");if(i&&s.length>1){const o=s.find(a=>a.variant===i);if(o)return o}return s[0]}function U(t,l){const i=t.fonts.find(s=>s.id===l);if(i)return{id:i.id,fontUrl:i.font_url,fontFamily:i.font_family,letterSpacing:i.letter_spacing??0,letterSpacingOutline:i.letter_spacing_outline??0,outlineWidth:i.outline_width??0,fixedColours:i.fixed_colours}}function v(t,l,i,s,o,a){const{library:h}=o,u=[];for(const[r,f]of Object.entries(s)){if(f.type!=="text")continue;const d=f,{meta:e}=d;if(!e?.text)continue;const{libraryItemID:z}=d,g=h[z];if(!g||!P(g))continue;const y=e.font;if(y===void 0)continue;const m=U(g,y);if(!m)continue;const c=A(l,r,a);if(!c)continue;let x=null,b=null;if(e.colours&&g.palette!=null)try{const n=t.palettes.getUsedColours(g.palette,e.colours);x=n[0]??null,b=n[1]??null}catch(n){console.warn(`[extractTextPlacements] Colour resolution failed for ${r}:`,n)}const S=o.byId?.[r],p={vertical:"top",horizontal:"left"};if(S){const n=S;n.vertical_alignment&&(p.vertical=n.vertical_alignment),n.horizontal_alignment&&(p.horizontal=n.horizontal_alignment)}u.push({placementId:r,displayName:r,source:i,text:e.text,font:m,isCurved:e.isCurved??!1,isDistributed:e.isDistributed??!1,verticalAlignment:e.verticalAlignment??p.vertical,horizontalAlignment:e.horizontalAlignment??p.horizontal,rotation:e.rotation??0,spacing:e.spacing??null,fillColour:x,outlineColour:b,position:{width:c.width,height:c.height,physicalWidth:c.physical_width_decimal,physicalHeight:c.physical_height_decimal,physicalUnits:c.physical_size_units,baselinePath:c.baseline_path,rotation:c.rotation},userSetPhysicalSize:e.retainedPhysicalSize??null,fontMetrics:(()=>{const n=e.fontMetrics??{size:0,height:0,baseline:0,width:0},C=n.physicalFontSize||(n.physicalHeight!=null&&n.physicalHeight>0&&n.physicalUnits!=null?[n.physicalHeight,n.physicalUnits]:void 0);return{...n,physicalFontSize:C}})(),manufacturingMethod:e.manufacturing_method??""})}return u}function O(t){const l=t.getState(),i=w(t);if(!i)return console.warn("[extractTextPlacements] No matching garment found"),[];const s=typeof t.getCurrentVariant=="function"?t.getCurrentVariant():void 0,o=[],a=l.vals?.placements,h=t.settings.placements;a&&h&&o.push(...v(t,i,"placements",a,h,s));const u=l.vals?.embellishments,r=t.settings.embellishments;return u&&r&&o.push(...v(t,i,"embellishments",u,r,s)),o.length===0&&console.warn("[extractTextPlacements] No text entries found in either placements or embellishments"),o}function _(t){return t==="center"?"centre":t}function T(t){let l=_(t.horizontalAlignment);t.isDistributed&&(l="distributed");let i;t.position.baselinePath?i={type:"custom",path:t.position.baselinePath}:t.isCurved?i="curved":i="flat";const s=t.userSetPhysicalSize?[t.userSetPhysicalSize[0],t.userSetPhysicalSize[1]]:t.fontMetrics.physicalFontSize!=null?t.fontMetrics.physicalFontSize:[t.position.physicalHeight,t.position.physicalUnits],a={outlineWidth:t.outlineColour!=null?t.font.outlineWidth:0,letterSpacing:t.spacing?.letter_spacing??0,letterSpacingOutline:t.spacing?.letter_spacing_outline??0},h=t.fillColour?{hex:t.fillColour.hex,reference:t.fillColour.reference??void 0}:void 0,u=t.outlineColour?{hex:t.outlineColour.hex,reference:t.outlineColour.reference??void 0}:void 0;let r;if(t.font.fixedColours){const f={};for(const[d,e]of Object.entries(t.font.fixedColours))f[d]={hex:e.hex,reference:e.reference};r=f}return{text:t.text,fontUrl:t.font.fontUrl,physicalSize:s,fontSizeFallback:t.fontMetrics.size,boxDimensions:{width:t.position.width,height:t.position.height,physicalWidth:t.position.physicalWidth,physicalHeight:t.position.physicalHeight,physicalUnits:t.position.physicalUnits},spacing:a,verticalAlignment:_(t.verticalAlignment),horizontalAlignment:l,baseline:i,fillColour:h,strokeColour:u,colourMap:r}}exports.extractTextPlacements=O;exports.mapStateToRendererOptions=T;
|
package/dist/ce-adapter/index.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
function
|
|
1
|
+
function w(t) {
|
|
2
2
|
return t.type === "text";
|
|
3
3
|
}
|
|
4
4
|
function P(t) {
|
|
5
5
|
const i = t.getState().garment;
|
|
6
6
|
return t.settings.garments.find((s) => s.slug === i);
|
|
7
7
|
}
|
|
8
|
-
function
|
|
9
|
-
if (t.embellishment_positions)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
);
|
|
8
|
+
function A(t, l, i) {
|
|
9
|
+
if (!t.embellishment_positions) return;
|
|
10
|
+
const s = t.embellishment_positions.filter((o) => o.placement !== l ? !1 : !i || !o.variant ? !0 : o.variant === i || o.variant.toLowerCase() === "common");
|
|
11
|
+
if (i && s.length > 1) {
|
|
12
|
+
const o = s.find((a) => a.variant === i);
|
|
13
|
+
if (o) return o;
|
|
14
|
+
}
|
|
15
|
+
return s[0];
|
|
13
16
|
}
|
|
14
|
-
function
|
|
15
|
-
const i = t.fonts.find((s) => s.id ===
|
|
17
|
+
function U(t, l) {
|
|
18
|
+
const i = t.fonts.find((s) => s.id === l);
|
|
16
19
|
if (i)
|
|
17
20
|
return {
|
|
18
21
|
id: i.id,
|
|
@@ -24,109 +27,109 @@ function A(t, o) {
|
|
|
24
27
|
fixedColours: i.fixed_colours
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
|
-
function
|
|
28
|
-
const { library:
|
|
29
|
-
for (const [
|
|
30
|
-
if (
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
33
|
-
const { libraryItemID:
|
|
34
|
-
if (!g || !
|
|
35
|
-
const y =
|
|
30
|
+
function S(t, l, i, s, o, a) {
|
|
31
|
+
const { library: h } = o, u = [];
|
|
32
|
+
for (const [r, f] of Object.entries(s)) {
|
|
33
|
+
if (f.type !== "text") continue;
|
|
34
|
+
const d = f, { meta: e } = d;
|
|
35
|
+
if (!e?.text) continue;
|
|
36
|
+
const { libraryItemID: z } = d, g = h[z];
|
|
37
|
+
if (!g || !w(g)) continue;
|
|
38
|
+
const y = e.font;
|
|
36
39
|
if (y === void 0) continue;
|
|
37
|
-
const m =
|
|
40
|
+
const m = U(g, y);
|
|
38
41
|
if (!m) continue;
|
|
39
|
-
const
|
|
40
|
-
if (!
|
|
42
|
+
const c = A(l, r, a);
|
|
43
|
+
if (!c) continue;
|
|
41
44
|
let x = null, b = null;
|
|
42
|
-
if (
|
|
45
|
+
if (e.colours && g.palette != null)
|
|
43
46
|
try {
|
|
44
|
-
const
|
|
47
|
+
const n = t.palettes.getUsedColours(
|
|
45
48
|
g.palette,
|
|
46
|
-
|
|
49
|
+
e.colours
|
|
47
50
|
);
|
|
48
|
-
x =
|
|
49
|
-
} catch (
|
|
50
|
-
console.warn(`[extractTextPlacements] Colour resolution failed for ${
|
|
51
|
+
x = n[0] ?? null, b = n[1] ?? null;
|
|
52
|
+
} catch (n) {
|
|
53
|
+
console.warn(`[extractTextPlacements] Colour resolution failed for ${r}:`, n);
|
|
51
54
|
}
|
|
52
|
-
const
|
|
55
|
+
const v = o.byId?.[r], p = {
|
|
53
56
|
vertical: "top",
|
|
54
57
|
horizontal: "left"
|
|
55
58
|
};
|
|
56
|
-
if (
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
+
if (v) {
|
|
60
|
+
const n = v;
|
|
61
|
+
n.vertical_alignment && (p.vertical = n.vertical_alignment), n.horizontal_alignment && (p.horizontal = n.horizontal_alignment);
|
|
59
62
|
}
|
|
60
|
-
|
|
61
|
-
placementId:
|
|
62
|
-
displayName:
|
|
63
|
+
u.push({
|
|
64
|
+
placementId: r,
|
|
65
|
+
displayName: r,
|
|
63
66
|
source: i,
|
|
64
|
-
text:
|
|
67
|
+
text: e.text,
|
|
65
68
|
font: m,
|
|
66
|
-
isCurved:
|
|
67
|
-
isDistributed:
|
|
68
|
-
verticalAlignment:
|
|
69
|
-
horizontalAlignment:
|
|
70
|
-
rotation:
|
|
71
|
-
spacing:
|
|
69
|
+
isCurved: e.isCurved ?? !1,
|
|
70
|
+
isDistributed: e.isDistributed ?? !1,
|
|
71
|
+
verticalAlignment: e.verticalAlignment ?? p.vertical,
|
|
72
|
+
horizontalAlignment: e.horizontalAlignment ?? p.horizontal,
|
|
73
|
+
rotation: e.rotation ?? 0,
|
|
74
|
+
spacing: e.spacing ?? null,
|
|
72
75
|
fillColour: x,
|
|
73
76
|
outlineColour: b,
|
|
74
77
|
position: {
|
|
75
|
-
width:
|
|
76
|
-
height:
|
|
77
|
-
physicalWidth:
|
|
78
|
-
physicalHeight:
|
|
79
|
-
physicalUnits:
|
|
80
|
-
baselinePath:
|
|
81
|
-
rotation:
|
|
78
|
+
width: c.width,
|
|
79
|
+
height: c.height,
|
|
80
|
+
physicalWidth: c.physical_width_decimal,
|
|
81
|
+
physicalHeight: c.physical_height_decimal,
|
|
82
|
+
physicalUnits: c.physical_size_units,
|
|
83
|
+
baselinePath: c.baseline_path,
|
|
84
|
+
rotation: c.rotation
|
|
82
85
|
},
|
|
83
|
-
userSetPhysicalSize:
|
|
86
|
+
userSetPhysicalSize: e.retainedPhysicalSize ?? null,
|
|
84
87
|
fontMetrics: (() => {
|
|
85
|
-
const
|
|
86
|
-
return { ...
|
|
88
|
+
const n = e.fontMetrics ?? { size: 0, height: 0, baseline: 0, width: 0 }, C = n.physicalFontSize || (n.physicalHeight != null && n.physicalHeight > 0 && n.physicalUnits != null ? [n.physicalHeight, n.physicalUnits] : void 0);
|
|
89
|
+
return { ...n, physicalFontSize: C };
|
|
87
90
|
})(),
|
|
88
|
-
manufacturingMethod:
|
|
91
|
+
manufacturingMethod: e.manufacturing_method ?? ""
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
|
-
return
|
|
94
|
+
return u;
|
|
92
95
|
}
|
|
93
|
-
function
|
|
94
|
-
const
|
|
96
|
+
function F(t) {
|
|
97
|
+
const l = t.getState(), i = P(t);
|
|
95
98
|
if (!i)
|
|
96
99
|
return console.warn("[extractTextPlacements] No matching garment found"), [];
|
|
97
|
-
const s = [],
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
return
|
|
100
|
+
const s = typeof t.getCurrentVariant == "function" ? t.getCurrentVariant() : void 0, o = [], a = l.vals?.placements, h = t.settings.placements;
|
|
101
|
+
a && h && o.push(...S(t, i, "placements", a, h, s));
|
|
102
|
+
const u = l.vals?.embellishments, r = t.settings.embellishments;
|
|
103
|
+
return u && r && o.push(...S(t, i, "embellishments", u, r, s)), o.length === 0 && console.warn("[extractTextPlacements] No text entries found in either placements or embellishments"), o;
|
|
101
104
|
}
|
|
102
105
|
function _(t) {
|
|
103
106
|
return t === "center" ? "centre" : t;
|
|
104
107
|
}
|
|
105
|
-
function
|
|
106
|
-
let
|
|
107
|
-
t.isDistributed && (
|
|
108
|
+
function H(t) {
|
|
109
|
+
let l = _(t.horizontalAlignment);
|
|
110
|
+
t.isDistributed && (l = "distributed");
|
|
108
111
|
let i;
|
|
109
112
|
t.position.baselinePath ? i = { type: "custom", path: t.position.baselinePath } : t.isCurved ? i = "curved" : i = "flat";
|
|
110
113
|
const s = t.userSetPhysicalSize ? [
|
|
111
114
|
t.userSetPhysicalSize[0],
|
|
112
115
|
t.userSetPhysicalSize[1]
|
|
113
|
-
] : t.fontMetrics.physicalFontSize != null ? t.fontMetrics.physicalFontSize : [t.position.physicalHeight, t.position.physicalUnits],
|
|
116
|
+
] : t.fontMetrics.physicalFontSize != null ? t.fontMetrics.physicalFontSize : [t.position.physicalHeight, t.position.physicalUnits], a = {
|
|
114
117
|
outlineWidth: t.outlineColour != null ? t.font.outlineWidth : 0,
|
|
115
118
|
letterSpacing: t.spacing?.letter_spacing ?? 0,
|
|
116
119
|
letterSpacingOutline: t.spacing?.letter_spacing_outline ?? 0
|
|
117
|
-
},
|
|
120
|
+
}, h = t.fillColour ? {
|
|
118
121
|
hex: t.fillColour.hex,
|
|
119
122
|
reference: t.fillColour.reference ?? void 0
|
|
120
|
-
} : void 0,
|
|
123
|
+
} : void 0, u = t.outlineColour ? {
|
|
121
124
|
hex: t.outlineColour.hex,
|
|
122
125
|
reference: t.outlineColour.reference ?? void 0
|
|
123
126
|
} : void 0;
|
|
124
|
-
let
|
|
127
|
+
let r;
|
|
125
128
|
if (t.font.fixedColours) {
|
|
126
129
|
const f = {};
|
|
127
|
-
for (const [
|
|
128
|
-
f[
|
|
129
|
-
|
|
130
|
+
for (const [d, e] of Object.entries(t.font.fixedColours))
|
|
131
|
+
f[d] = { hex: e.hex, reference: e.reference };
|
|
132
|
+
r = f;
|
|
130
133
|
}
|
|
131
134
|
return {
|
|
132
135
|
text: t.text,
|
|
@@ -140,18 +143,18 @@ function F(t) {
|
|
|
140
143
|
physicalHeight: t.position.physicalHeight,
|
|
141
144
|
physicalUnits: t.position.physicalUnits
|
|
142
145
|
},
|
|
143
|
-
spacing:
|
|
146
|
+
spacing: a,
|
|
144
147
|
verticalAlignment: _(
|
|
145
148
|
t.verticalAlignment
|
|
146
149
|
),
|
|
147
|
-
horizontalAlignment:
|
|
150
|
+
horizontalAlignment: l,
|
|
148
151
|
baseline: i,
|
|
149
|
-
fillColour:
|
|
150
|
-
strokeColour:
|
|
151
|
-
colourMap:
|
|
152
|
+
fillColour: h,
|
|
153
|
+
strokeColour: u,
|
|
154
|
+
colourMap: r
|
|
152
155
|
};
|
|
153
156
|
}
|
|
154
157
|
export {
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
F as extractTextPlacements,
|
|
159
|
+
H as mapStateToRendererOptions
|
|
157
160
|
};
|