@unmade/text-renderer 1.1.1 → 1.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/README.md CHANGED
@@ -46,6 +46,107 @@ const { getRenderedText } = await import('@unmade/text-renderer/node');
46
46
 
47
47
  **CE Lambda integration**: When wiring text-renderer into the Configuration Engine Lambda, use the `/node` ESM entry (`@unmade/text-renderer/node`) from an ESM Lambda handler, or use the `await import()` pattern above from an existing CJS handler. The `ce-adapter` sub-package (`@unmade/text-renderer/ce-adapter`) retains a CJS build and can be `require()`'d normally.
48
48
 
49
+ ## Choosing a font size
50
+
51
+ `getRenderedText` draws text at a size you give it. These work out what that size should be.
52
+
53
+ ```typescript
54
+ import { getFontSizeToFitBox, loadFont } from '@unmade/text-renderer';
55
+
56
+ const font = await loadFont('https://example.com/font.ttf');
57
+
58
+ const { fontSize, fits, boundBy } = getFontSizeToFitBox({
59
+ font,
60
+ text: 'HELLO',
61
+ boxDimensions: {
62
+ width: 500,
63
+ height: 200,
64
+ physicalWidth: 100,
65
+ physicalHeight: 40,
66
+ physicalUnits: 'mm',
67
+ },
68
+ size: { mode: 'fitToBox', minFontSize: 20, maxFontSize: 200 },
69
+ spacing: { outlineWidth: 0, letterSpacing: 0, letterSpacingOutline: 0 },
70
+ baseline: 'flat', // or 'curved', or { type: 'custom', path: '...' }
71
+ verticalAlignment: 'centre', // affects where a curved baseline sits
72
+ });
73
+ ```
74
+
75
+ For flat and custom baselines this is a direct calculation rather than a search: glyph
76
+ widths, outline stroke and letter spacing are all `fontSize * constant`, so measuring
77
+ once at a reference size and solving for a scale factor is exact. Curved baselines are
78
+ bisected instead, against both limits at once: neither the arc's length nor the arch's
79
+ position is proportional to font size, so both are evaluated at each candidate size.
80
+
81
+ The height check asks where the text will actually land, not how tall it is. The arch is
82
+ positioned from a standard capital, so that its shape does not change as characters are
83
+ typed — which means a descender hangs below the arch's ends, outside a box the arch itself
84
+ sits comfortably inside. The check reads the arch's placement from the same function that
85
+ generates the baseline path, so a size it accepts is one the renderer can draw.
86
+
87
+ ### The three ways to decide a size
88
+
89
+ `size` covers each of them, so none is a special case:
90
+
91
+ | `size` | Meaning |
92
+ |---|---|
93
+ | `{ mode: 'fitToBox', minFontSize, maxFontSize }` | Solve for the largest size that fits the box |
94
+ | `{ mode: 'fontSize', fontSize }` | Pin a size in pixels |
95
+ | `{ mode: 'physicalSize', physicalSize: [60, 'mm'] }` | Pin a physical size — what a fixed size preset is |
96
+
97
+ `minFontSize` and `maxFontSize` belong only to `fitToBox`, because they only mean
98
+ anything while solving. A pinned size overrides them by design.
99
+
100
+ Pinning inverts the problem. Instead of the text being fixed and the size free, the
101
+ size is fixed and the **text length** becomes the free variable — hold 60mm, and fit as
102
+ many characters as will go.
103
+
104
+ ### Reading the result
105
+
106
+ ```typescript
107
+ interface FitTextToBoxResult {
108
+ fontSize: number;
109
+ fits: boolean; // false when it won't fit even at the smallest allowed size
110
+ boundBy: 'width' | 'height' | 'requestedSize';
111
+ }
112
+ ```
113
+
114
+ `boundBy` says which limit decided the outcome — when it fits, what capped the size;
115
+ when it doesn't, what couldn't be satisfied. Callers generally need to respond
116
+ differently to each: text too wide for its box and text too tall for its box call for
117
+ different remedies, and in the configuration engine they surface as different messages.
118
+
119
+ `requestedSize` means the box wasn't the limiting factor: either `maxFontSize` was
120
+ reached while solving, or a pinned size was simply granted.
121
+
122
+ ### Shortening text that won't fit
123
+
124
+ ```typescript
125
+ import { fitAndTruncateTextToBox } from '@unmade/text-renderer';
126
+
127
+ const result = fitAndTruncateTextToBox({ font, text, boxDimensions, size, spacing });
128
+
129
+ result.text; // possibly shortened
130
+ result.truncated; // whether anything was removed
131
+ result.linesDropped; // whole lines removed
132
+ result.charactersRemoved; // trailing characters removed
133
+ ```
134
+
135
+ Whole lines are dropped before characters are trimmed. Both counts are reported rather
136
+ than a single mode, because one call can do both — drop a line, then find the remainder
137
+ still needs trimming — and because the caller usually wants to say something different
138
+ about each.
139
+
140
+ Works in every `size` mode. With a pinned size it is the only thing that can give: the
141
+ size stays put and the text shortens.
142
+
143
+ ### Rotation
144
+
145
+ Deliberately absent. Pass the box you want text laid out in and apply rotation to the
146
+ rendered output. Rotating the frame rather than reasoning about rotation inside the
147
+ solver keeps it working in one coordinate space, and is also what lets a rotated tall
148
+ placement use its long side for the baseline.
149
+
49
150
  ## Configuration Engine adapter
50
151
 
51
152
  The `ce-adapter` sub-package bridges CE (Configuration Engine) editor state to renderer options:
@@ -88,6 +189,89 @@ comparisons/{designUrlHash}/{timestamp}/{placementId}/
88
189
  renderer-options.json # GetRenderedTextOptions (mapped TR input)
89
190
  ```
90
191
 
192
+ ## Platform bundle suites
193
+
194
+ The package builds a separate bundle per platform — browser, worker and node —
195
+ each resolving a different variant of `@unmade/platform`:
196
+
197
+ | | `DOMPoint` | `DOMMatrix` | `DOMParser` |
198
+ |---|---|---|---|
199
+ | node | polyfill | `@thednp` polyfill | linkedom |
200
+ | worker | **native** | `@thednp` polyfill | linkedom/worker |
201
+ | browser | native | native | native |
202
+
203
+ `__tests__/platform/` tests those built bundles, each in the host it ships into:
204
+ the node bundle in the test process, the browser bundle on a page, and the worker
205
+ bundle inside a real `DedicatedWorkerGlobalScope`. Nothing else runs them — the
206
+ unit suite tests `src` under node, where neither `DOMPoint` nor `DOMMatrix`
207
+ exists, so it can only ever exercise the polyfills.
208
+
209
+ ```bash
210
+ npm run test:platform
211
+ ```
212
+
213
+ It builds first, because it loads `dist` and a stale bundle would otherwise give
214
+ a green run against code that is no longer there. It needs a browser:
215
+ `npx playwright install chromium`.
216
+
217
+ ### The two suites
218
+
219
+ **`render.test.ts`** draws every scenario in `scenarios.ts` on all three bundles,
220
+ through both public render entry points — `getRenderedText`, which loads its own
221
+ font, and `renderText`, which takes one already loaded. Each render is rasterised
222
+ here, by one resvg call for all three, and checked twice:
223
+
224
+ - **pixel-identical to the node bundle's, with no tolerance.** All three go
225
+ through the same rasteriser in the same process, so nothing legitimate can move
226
+ a pixel. This is the assertion that the differing geometry and DOM
227
+ implementations do not reach the artwork.
228
+ - **within 1% of a committed reference** in `platform/references/`, so a change
229
+ that shifts all three together is caught as well.
230
+
231
+ **`api.test.ts`** covers the rest of the public API, which nothing else would
232
+ call on the browser or worker bundles: constants, baseline generation and
233
+ validation, measurement, font loading and the font cache. Each is called with
234
+ identical inputs on all three bundles and the results compared. It also asserts
235
+ the three export the same names, that every export has a probe, and that each
236
+ bundle ran in the host it is built for — a `typeof DOMPoint` check cannot tell a
237
+ page from a worker, since both have the native globals.
238
+
239
+ ### The report
240
+
241
+ Every run writes `__tests__/platform-output/report.html`: one self-contained file
242
+ with each scenario's committed reference beside what each bundle drew, badged
243
+ with both comparisons, plus the diff images and the browser's user agent. It is
244
+ gitignored and collected as a CI artifact from `platform-test` whether the run
245
+ passed or failed, so a by-eye check is always available. The SVG each bundle
246
+ produced is written alongside it, so a difference the report shows as pixels can
247
+ be diffed as text.
248
+
249
+ ### Fonts
250
+
251
+ Served from the test process, so every platform fetches byte-identical bytes from
252
+ the same URL and the font is never a variable. Three, chosen for what they
253
+ exercise:
254
+
255
+ - **Bangers** — irregular display face: curves, diagonals, varying stroke widths.
256
+ Caps only.
257
+ - **Lora** — high-contrast serif with true lowercase, so descenders feed the
258
+ baseline and line-height calculation.
259
+ - **giants-color** — glyphs stored as SVG documents, the only path that goes
260
+ through a `DOMParser` and back out through an `XMLSerializer`.
261
+
262
+ The two added faces are OFL, with their licences beside them in
263
+ `__tests__/fixtures/`.
264
+
265
+ ### Updating the references
266
+
267
+ ```bash
268
+ UPDATE_FIXTURES=true npm run test:platform
269
+ ```
270
+
271
+ Rewrites every reference from the node bundle's render, then still checks the
272
+ other two against what it just stored. Review the diffs in the report before
273
+ committing.
274
+
91
275
  ## Regression tests
92
276
 
93
277
  Regression tests live in `__tests__/regression.test.ts` and use fixture pairs in `__tests__/fixtures/regression/`:
@@ -1,4 +1,4 @@
1
- import { t as e } from "./chunk-3b4jIN3o.js";
1
+ import { t as e } from "./rolldown-runtime-DtPi1Y-2.js";
2
2
  //#region __vite-browser-external
3
3
  var t = /* @__PURE__ */ e(((e, t) => {
4
4
  t.exports = {};
@@ -28,6 +28,25 @@ export interface GenerateCurvedBaselineOptions {
28
28
  textHeight: number;
29
29
  verticalAlignment: VerticalTextAlignmentValues;
30
30
  }
31
+ /** Where the arch sits vertically: its peak, and the baseline at its ends. */
32
+ export interface CurvedBaselineGeometry {
33
+ /** y of the peak — the highest point of the baseline. */
34
+ curveTop: number;
35
+ /** y of the two ends — the lowest points of the baseline. */
36
+ curveBottom: number;
37
+ /** Horizontal gap left at each end so the outer glyphs are not clipped. */
38
+ curveXInset: number;
39
+ }
40
+ /**
41
+ * The arch's placement, on its own so that the path and anything reasoning
42
+ * about where the text will land come from one calculation.
43
+ *
44
+ * Sizing needs this: it has to know where the renderer will actually put the
45
+ * baseline before it can say whether the text riding it clears the box. Working
46
+ * that out separately is what let the fit engine believe a size fitted while
47
+ * the render clipped.
48
+ */
49
+ export declare const curvedBaselineGeometry: ({ boxDimensions, baseline, textHeight, verticalAlignment, }: GenerateCurvedBaselineOptions) => CurvedBaselineGeometry;
31
50
  /**
32
51
  * Generate a path command to curve text and align that text to the top of the box.
33
52
  * Uses two quadratic bezier curves — one from the bottom left to the peak, and a
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});function e(e){return e.type===`text`}function t(e){let t=e.getState().garment;return e.settings.garments.find(e=>e.slug===t)}function n(e,t,n){if(!e.embellishment_positions)return;let r=e.embellishment_positions.filter(e=>e.placement===t?!n||!e.variant?!0:e.variant===n||e.variant.toLowerCase()===`common`:!1);if(n&&r.length>1){let e=r.find(e=>e.variant===n);if(e)return e}return r[0]}function r(e,t){let n=e.fonts.find(e=>e.id===t);if(n)return{id:n.id,fontUrl:n.font_url,fontFamily:n.font_family,letterSpacing:n.letter_spacing??0,letterSpacingOutline:n.letter_spacing_outline??0,outlineWidth:n.outline_width??0,fixedColours:n.fixed_colours}}function i(t,i,a,o,s,c){let{library:l}=s,u=[];for(let[d,f]of Object.entries(o)){if(f.type!==`text`)continue;let o=f,{meta:p}=o;if(!p?.text)continue;let{libraryItemID:m}=o,h=l[m];if(!h||!e(h))continue;let g=p.font;if(g===void 0)continue;let _=r(h,g);if(!_)continue;let v=n(i,d,c);if(!v)continue;let y=null,b=null;if(p.colours&&h.palette!=null)try{let e=t.palettes.getUsedColours(h.palette,p.colours);y=e[0]??null,b=e[1]??null}catch(e){console.warn(`[extractTextPlacements] Colour resolution failed for ${d}:`,e)}let x=s.byId?.[d],S={vertical:`top`,horizontal:`left`};if(x){let e=x;e.vertical_alignment&&(S.vertical=e.vertical_alignment),e.horizontal_alignment&&(S.horizontal=e.horizontal_alignment)}u.push({placementId:d,displayName:d,source:a,text:p.text,font:_,isCurved:p.isCurved??!1,isDistributed:p.isDistributed??!1,verticalAlignment:p.verticalAlignment??S.vertical,horizontalAlignment:p.horizontalAlignment??S.horizontal,rotation:p.rotation??0,spacing:p.spacing??null,fillColour:y,outlineColour:b,position:{width:v.width,height:v.height,physicalWidth:v.physical_width_decimal,physicalHeight:v.physical_height_decimal,physicalUnits:v.physical_size_units,baselinePath:v.baseline_path,rotation:v.rotation},userSetPhysicalSize:p.retainedPhysicalSize??null,fontMetrics:(()=>{let e=p.fontMetrics??{size:0,height:0,baseline:0,width:0},t=e.physicalFontSize||(e.physicalHeight!=null&&e.physicalHeight>0&&e.physicalUnits!=null?[e.physicalHeight,e.physicalUnits]:void 0);return{...e,physicalFontSize:t}})(),manufacturingMethod:p.manufacturing_method??``})}return u}function a(e){let n=e.getState(),r=t(e);if(!r)return console.warn(`[extractTextPlacements] No matching garment found`),[];let a=typeof e.getCurrentVariant==`function`?e.getCurrentVariant():void 0,o=[],s=n.vals?.placements,c=e.settings.placements;s&&c&&o.push(...i(e,r,`placements`,s,c,a));let l=n.vals?.embellishments,u=e.settings.embellishments;return l&&u&&o.push(...i(e,r,`embellishments`,l,u,a)),o.length===0&&console.warn(`[extractTextPlacements] No text entries found in either placements or embellishments`),o}function o(e){return e===`center`?`centre`:e}function s(e){let t=o(e.horizontalAlignment);e.isDistributed&&(t=`distributed`);let n;n=e.position.baselinePath?{type:`custom`,path:e.position.baselinePath}:e.isCurved?`curved`:`flat`;let r=e.userSetPhysicalSize?[e.userSetPhysicalSize[0],e.userSetPhysicalSize[1]]:e.fontMetrics.physicalFontSize==null?[e.position.physicalHeight,e.position.physicalUnits]:e.fontMetrics.physicalFontSize,i={outlineWidth:e.outlineColour==null?0:e.font.outlineWidth,letterSpacing:e.spacing?.letter_spacing??0,letterSpacingOutline:e.spacing?.letter_spacing_outline??0},a=e.fillColour?{hex:e.fillColour.hex,reference:e.fillColour.reference??void 0}:void 0,s=e.outlineColour?{hex:e.outlineColour.hex,reference:e.outlineColour.reference??void 0}:void 0,c;if(e.font.fixedColours){let t={};for(let[n,r]of Object.entries(e.font.fixedColours))t[n]={hex:r.hex,reference:r.reference};c=t}return{text:e.text,fontUrl:e.font.fontUrl,physicalSize:r,fontSizeFallback:e.fontMetrics.size,boxDimensions:{width:e.position.width,height:e.position.height,physicalWidth:e.position.physicalWidth,physicalHeight:e.position.physicalHeight,physicalUnits:e.position.physicalUnits},spacing:i,verticalAlignment:o(e.verticalAlignment),horizontalAlignment:t,baseline:n,fillColour:a,strokeColour:s,colourMap:c}}exports.extractTextPlacements=a,exports.mapStateToRendererOptions=s;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});function e(e){return e.type===`text`}function t(e){let t=e.getState().garment;return e.settings.garments.find(e=>e.slug===t)}function n(e,t,n){if(!e.embellishment_positions)return;let r=e.embellishment_positions.filter(e=>e.placement===t?!n||!e.variant||e.variant===n||e.variant.toLowerCase()===`common`:!1);if(n&&r.length>1){let e=r.find(e=>e.variant===n);if(e)return e}return r[0]}function r(e,t){let n=e.fonts.find(e=>e.id===t);if(n)return{id:n.id,fontUrl:n.font_url,fontFamily:n.font_family,letterSpacing:n.letter_spacing??0,letterSpacingOutline:n.letter_spacing_outline??0,outlineWidth:n.outline_width??0,fixedColours:n.fixed_colours}}function i(t,i,a,o,s,c){let{library:l}=s,u=[];for(let[d,f]of Object.entries(o)){if(f.type!==`text`)continue;let o=f,{meta:p}=o;if(!p?.text)continue;let{libraryItemID:m}=o,h=l[m];if(!h||!e(h))continue;let g=p.font;if(g===void 0)continue;let _=r(h,g);if(!_)continue;let v=n(i,d,c);if(!v)continue;let y=null,b=null;if(p.colours&&h.palette!=null)try{let e=t.palettes.getUsedColours(h.palette,p.colours);y=e[0]??null,b=e[1]??null}catch(e){console.warn(`[extractTextPlacements] Colour resolution failed for ${d}:`,e)}let x=s.byId?.[d],S={vertical:`top`,horizontal:`left`};if(x){let e=x;e.vertical_alignment&&(S.vertical=e.vertical_alignment),e.horizontal_alignment&&(S.horizontal=e.horizontal_alignment)}u.push({placementId:d,displayName:d,source:a,text:p.text,font:_,isCurved:p.isCurved??!1,isDistributed:p.isDistributed??!1,verticalAlignment:p.verticalAlignment??S.vertical,horizontalAlignment:p.horizontalAlignment??S.horizontal,rotation:p.rotation??0,spacing:p.spacing??null,fillColour:y,outlineColour:b,position:{width:v.width,height:v.height,physicalWidth:v.physical_width_decimal,physicalHeight:v.physical_height_decimal,physicalUnits:v.physical_size_units,baselinePath:v.baseline_path,rotation:v.rotation},userSetPhysicalSize:p.retainedPhysicalSize??null,fontMetrics:(()=>{let e=p.fontMetrics??{size:0,height:0,baseline:0,width:0},t=e.physicalFontSize||(e.physicalHeight!=null&&e.physicalHeight>0&&e.physicalUnits!=null?[e.physicalHeight,e.physicalUnits]:void 0);return{...e,physicalFontSize:t}})(),manufacturingMethod:p.manufacturing_method??``})}return u}function a(e){let n=e.getState(),r=t(e);if(!r)return console.warn(`[extractTextPlacements] No matching garment found`),[];let a=typeof e.getCurrentVariant==`function`?e.getCurrentVariant():void 0,o=[],s=n.vals?.placements,c=e.settings.placements;s&&c&&o.push(...i(e,r,`placements`,s,c,a));let l=n.vals?.embellishments,u=e.settings.embellishments;return l&&u&&o.push(...i(e,r,`embellishments`,l,u,a)),o.length===0&&console.warn(`[extractTextPlacements] No text entries found in either placements or embellishments`),o}function o(e){return e===`center`?`centre`:e}function s(e){let t=o(e.horizontalAlignment);e.isDistributed&&(t=`distributed`);let n;n=e.position.baselinePath?{type:`custom`,path:e.position.baselinePath}:e.isCurved?`curved`:`flat`;let r=e.userSetPhysicalSize?[e.userSetPhysicalSize[0],e.userSetPhysicalSize[1]]:e.fontMetrics.physicalFontSize==null?[e.position.physicalHeight,e.position.physicalUnits]:e.fontMetrics.physicalFontSize,i={outlineWidth:e.outlineColour==null?0:e.font.outlineWidth,letterSpacing:e.spacing?.letter_spacing??0,letterSpacingOutline:e.spacing?.letter_spacing_outline??0},a=e.fillColour?{hex:e.fillColour.hex,reference:e.fillColour.reference??void 0}:void 0,s=e.outlineColour?{hex:e.outlineColour.hex,reference:e.outlineColour.reference??void 0}:void 0,c;if(e.font.fixedColours){let t={};for(let[n,r]of Object.entries(e.font.fixedColours))t[n]={hex:r.hex,reference:r.reference};c=t}return{text:e.text,fontUrl:e.font.fontUrl,physicalSize:r,fontSizeFallback:e.fontMetrics.size,boxDimensions:{width:e.position.width,height:e.position.height,physicalWidth:e.position.physicalWidth,physicalHeight:e.position.physicalHeight,physicalUnits:e.position.physicalUnits},spacing:i,verticalAlignment:o(e.verticalAlignment),horizontalAlignment:t,baseline:n,fillColour:a,strokeColour:s,colourMap:c}}exports.extractTextPlacements=a,exports.mapStateToRendererOptions=s;
@@ -8,7 +8,7 @@ function t(e) {
8
8
  }
9
9
  function n(e, t, n) {
10
10
  if (!e.embellishment_positions) return;
11
- let r = e.embellishment_positions.filter((e) => e.placement === t ? !n || !e.variant ? !0 : e.variant === n || e.variant.toLowerCase() === "common" : !1);
11
+ let r = e.embellishment_positions.filter((e) => e.placement === t ? !n || !e.variant || e.variant === n || e.variant.toLowerCase() === "common" : !1);
12
12
  if (n && r.length > 1) {
13
13
  let e = r.find((e) => e.variant === n);
14
14
  if (e) return e;
@@ -0,0 +1,86 @@
1
+ import { LoadedFont } from '../loading';
2
+ import { BaselineConfig, BaselineType, BoxDimensions, PhysicalSize, TextSpacing, VerticalTextAlignmentValues } from '../types';
3
+ /**
4
+ * How the font size is arrived at.
5
+ *
6
+ * `fitToBox` solves for the largest size the box allows. The other two pin the
7
+ * size and leave the text length as the free variable instead — which is what a
8
+ * fixed size preset is: hold 60mm, and fit as many characters as will go. All
9
+ * three then behave identically downstream, so a pinned size is an ordinary mode
10
+ * rather than a special path.
11
+ */
12
+ export type FitSize = {
13
+ mode: 'fitToBox';
14
+ minFontSize: number;
15
+ maxFontSize: number;
16
+ } | {
17
+ mode: 'fontSize';
18
+ fontSize: number;
19
+ } | {
20
+ mode: 'physicalSize';
21
+ physicalSize: PhysicalSize;
22
+ };
23
+ export interface FitTextToBoxOptions {
24
+ font: LoadedFont;
25
+ text: string;
26
+ boxDimensions: BoxDimensions;
27
+ size: FitSize;
28
+ spacing?: TextSpacing;
29
+ baseline?: BaselineType | BaselineConfig;
30
+ verticalAlignment?: VerticalTextAlignmentValues;
31
+ }
32
+ /**
33
+ * Which limit decided the outcome.
34
+ *
35
+ * - `width` — the box width, or the arc/custom path length for a non-flat baseline
36
+ * - `height` — the box height
37
+ * - `requestedSize` — the size the caller asked for, reached before the box
38
+ * mattered. Covers both hitting `maxFontSize` when solving and simply getting
39
+ * the pinned size that was requested.
40
+ */
41
+ export type FitConstraint = 'width' | 'height' | 'requestedSize';
42
+ export interface FitTextToBoxResult {
43
+ fontSize: number;
44
+ /** False when the text cannot fit within the box even at minFontSize. */
45
+ fits: boolean;
46
+ /**
47
+ * When `fits`, the limit that capped the size. When not, the limit that could
48
+ * not be satisfied.
49
+ *
50
+ * Callers need this to tell one failure from another: overflowing the width
51
+ * and overflowing the height call for different responses, and in the
52
+ * configuration engine they surface as different messages to the user.
53
+ */
54
+ boundBy: FitConstraint;
55
+ }
56
+ export interface FitAndTruncateTextToBoxResult extends FitTextToBoxResult {
57
+ text: string;
58
+ truncated: boolean;
59
+ /**
60
+ * How the text was shortened. Reported separately because dropping a line and
61
+ * trimming characters are different outcomes with different messages, and a
62
+ * single pass can do both — drop a line, then still need to trim.
63
+ */
64
+ linesDropped: number;
65
+ charactersRemoved: number;
66
+ }
67
+ /**
68
+ * Calculate the font size at which `text` fills `boxDimensions` as closely
69
+ * as possible without overflowing, for any baseline shape. This replaces
70
+ * binary-searching for a fitting font size: for flat and custom baselines
71
+ * it's a direct O(1) calculation; for curved baselines it's a bounded
72
+ * numeric solve (see solveCurvedFit).
73
+ *
74
+ * `fits: false` means the text doesn't fit even at minFontSize — the caller
75
+ * (e.g. fitAndTruncateTextToBox) should either shorten the text or accept
76
+ * the overflow at minFontSize.
77
+ */
78
+ export declare const getFontSizeToFitBox: (options: FitTextToBoxOptions) => FitTextToBoxResult;
79
+ /**
80
+ * Like getFontSizeToFitBox, but when text doesn't fit even at minFontSize,
81
+ * shortens it (dropping the last whole line first if there is more than
82
+ * one, otherwise trimming trailing characters) and retries until it fits
83
+ * or there's nothing left to remove. Mirrors CE's existing truncation UX —
84
+ * text lost this way is a caller/product concern, not a rendering one.
85
+ */
86
+ export declare const fitAndTruncateTextToBox: (options: FitTextToBoxOptions) => FitAndTruncateTextToBoxResult;
@@ -1,3 +1,4 @@
1
+ import { PlatformMatrix } from '../types';
1
2
  export declare class Box {
2
3
  corners: DOMPoint[];
3
4
  x1: number;
@@ -13,7 +14,7 @@ export declare class Box {
13
14
  get x4(): number;
14
15
  get y4(): number;
15
16
  constructor(x1: number, y1: number, x2: number, y2: number, x3?: number, y3?: number, x4?: number, y4?: number);
16
- transform(matrix: DOMMatrix): void;
17
+ transform(matrix: PlatformMatrix): void;
17
18
  toPointsArray(): number[][];
18
19
  getBBox(): BBox;
19
20
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { GetRenderedTextOptions } from './types';
2
2
  export { type GenerateCurvedBaselineOptions, type GenerateFlatBaselineOptions, generateCurvedBaseline, generateFlatBaseline, normaliseBaselineOption, validateCustomBaselinePath, } from './baselines';
3
3
  export { DEFAULT_OUTLINE_FACTOR, DEFAULT_SPACING, LINE_HEIGHT, } from './constants';
4
+ export { type FitAndTruncateTextToBoxResult, type FitConstraint, type FitSize, type FitTextToBoxOptions, type FitTextToBoxResult, fitAndTruncateTextToBox, getFontSizeToFitBox, } from './fitting';
4
5
  export { getFontColours } from './glyph/svg-glyph-converter';
5
6
  export { getFontCache, type LoadedFont, loadFont } from './loading';
6
7
  export { type GetFontSizeFromPhysicalSizeOptions, type GetTextMetricsAtFontSizeOptions, getFontSizeFromPhysicalSize, getTextMetricsAtFontSize, } from './measuring';