@liiift-studio/steadygray 0.0.1 → 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/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # Steady Gray
2
2
 
3
+ Compositors call it colour — the aggregate grey of a text block. When some lines are denser than others, the paragraph looks uneven. Steady Gray measures ink pixel density per line by rendering to an off-screen Canvas, then adjusts letter-spacing until every line matches the target. Even colour, line by line.
4
+
3
5
  **[steadygray.com](https://steadygray.com)** · [npm](https://www.npmjs.com/package/@liiift-studio/steadygray) · [GitHub](https://github.com/Liiift-Studio/SteadyGray)
4
6
 
7
+ TypeScript · Canvas pixel sampling · React + Vanilla JS
8
+
5
9
  ---
6
10
 
7
11
  ## Install
@@ -10,7 +14,92 @@
10
14
  npm install @liiift-studio/steadygray
11
15
  ```
12
16
 
13
- See [steadygray.com](https://steadygray.com) for full API docs and a live demo.
17
+ ---
18
+
19
+ ## Usage
20
+
21
+ > **Next.js App Router:** this library uses browser APIs. Add `"use client"` to any component file that imports from it.
22
+
23
+ ### React component
24
+
25
+ ```tsx
26
+ import { GrayValueText } from '@liiift-studio/steadygray'
27
+
28
+ <GrayValueText maxAdjustment={0.05} calibrationFactor={2} linePreservation="scale">
29
+ Your paragraph text here...
30
+ </GrayValueText>
31
+ ```
32
+
33
+ `linePreservation="scale"` prevents line overflow by applying a `scaleX` transform after the spacing correction. Omit it if slight overflow is acceptable (e.g. when the element already has `overflow-x: hidden`).
34
+
35
+ ### React hook
36
+
37
+ ```tsx
38
+ import { useGrayValue } from '@liiift-studio/steadygray'
39
+
40
+ // Inside a React component:
41
+ const ref = useGrayValue({ maxAdjustment: 0.05, calibrationFactor: 2 })
42
+ return <p ref={ref}>{children}</p>
43
+ ```
44
+
45
+ The hook re-runs automatically on resize via `ResizeObserver` and after fonts load via `document.fonts.ready`.
46
+
47
+ ### Vanilla JS
48
+
49
+ ```ts
50
+ import { applyGrayValue, removeGrayValue, getCleanHTML } from '@liiift-studio/steadygray'
51
+
52
+ const el = document.querySelector('p')
53
+ const original = getCleanHTML(el)
54
+ const opts = { maxAdjustment: 0.05, calibrationFactor: 2 }
55
+
56
+ function run() {
57
+ applyGrayValue(el, original, opts)
58
+ }
59
+
60
+ run()
61
+ document.fonts.ready.then(run)
62
+
63
+ const ro = new ResizeObserver(() => run())
64
+ ro.observe(el)
65
+
66
+ // Later — disconnect and restore original markup:
67
+ // ro.disconnect()
68
+ // removeGrayValue(el, original)
69
+ ```
70
+
71
+ ### TypeScript
72
+
73
+ ```ts
74
+ import type { GrayValueOptions } from '@liiift-studio/steadygray'
75
+
76
+ const opts: GrayValueOptions = { targetDensity: 0.35, maxAdjustment: 0.05, linePreservation: 'scale' }
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Options
82
+
83
+ | Option | Default | Description |
84
+ |--------|---------|-------------|
85
+ | `targetDensity` | `'auto'` | Target optical density ratio (0–1). `'auto'` uses the average of all measured lines |
86
+ | `method` | `'letter-spacing'` | CSS spacing property to adjust per line: `'letter-spacing'` or `'word-spacing'` |
87
+ | `maxAdjustment` | `0.05` | Maximum spacing correction in em units. Positive and negative adjustments are both clamped to this value |
88
+ | `tolerance` | `0.01` | Minimum density difference before a correction is applied. Lines within this threshold of the target are left untouched |
89
+ | `calibrationFactor` | `2` | Correction strength — em spacing change per 1.0 density unit difference. Increase for more aggressive corrections |
90
+ | `lineDetection` | `'bcr'` | `'bcr'` reads actual browser layout — ground truth, works with any font and inline HTML. `'canvas'` uses `@chenglou/pretext` for arithmetic line breaking with no forced reflow on resize (`npm install @chenglou/pretext`). Falls back to `'bcr'` while pretext loads |
91
+ | `linePreservation` | `'none'` | `'none'` — line widths vary with the spacing correction (bounded by `maxAdjustment`). `'scale'` — applies a `scaleX` transform after correction so every line occupies exactly its original width; the density difference remains visible in glyph spacing but no line ever overflows the container |
92
+ | `as` | `'p'` | HTML element to render. *(React component only)* |
93
+
94
+ ---
95
+
96
+ ## How it works
97
+
98
+ Each detected line of text is rendered to an off-screen Canvas at the correct font size, weight, and family. The raw pixel data (`getImageData`) is read and ink pixels are counted — any pixel with alpha above a threshold is considered ink. The ratio of ink pixels to total pixels is the line's optical density. The average across all lines becomes the target (or you can set `targetDensity` manually). Each line then receives a `letter-spacing` (or `word-spacing`) correction proportional to its deviation from the target, clamped to `maxAdjustment`. The correction re-runs on resize and after fonts finish loading (`document.fonts.ready`).
99
+
100
+ **Line break safety:** Line breaks are always derived from the browser's natural layout — each run starts from the original HTML snapshot, detects lines at zero spacing, then locks them with `white-space: nowrap`. Word breaks never change as a result of the density correction.
101
+
102
+ **Width overflow:** The spacing correction intentionally changes each line's visual width. The maximum change is `maxAdjustment × characterCount`. At the default `maxAdjustment: 0.05em` and 60 characters per line at 16px, peak overflow is approximately 48px. Use `linePreservation: 'scale'` to prevent overflow entirely, or add `overflow-x: hidden` to the element's CSS if a small amount of clipping is acceptable.
14
103
 
15
104
  ---
16
105
 
@@ -21,3 +110,17 @@ See [steadygray.com](https://steadygray.com) for full API docs and a live demo.
21
110
  `package.json` at the repo root lists `next` as a devDependency. This is a **Vercel detection workaround** — not a real dependency of the npm package. Vercel's build system inspects the root `package.json` to detect the framework; without `next` present it falls back to a static build and skips the Next.js pipeline, breaking the `/site` subdirectory deploy.
22
111
 
23
112
  The package itself has zero runtime dependencies. Do not remove this entry.
113
+
114
+ ---
115
+
116
+ ## Future improvements
117
+
118
+ - **Variable axis equalization** — use `wght` or `wdth` instead of letter-spacing as the equalization mechanism, for fonts where spacing is less flexible than weight
119
+ - **Dark mode awareness** — invert the pixel-counting logic when rendering on a dark background, so the density measurement is consistent regardless of color scheme
120
+ - **Configurable canvas DPR** — allow overriding the device pixel ratio used for the measurement canvas, to trade accuracy for performance on high-density displays
121
+ - **Iterative convergence** — apply corrections in multiple passes until all lines converge within `tolerance`, rather than a single-pass linear estimate
122
+ - **Per-paragraph target** — `measureLineDensity` is exported as a low-level canvas primitive; a high-level `measureDensity(el)` wrapper that takes just an element would allow cross-paragraph normalization without manually building font strings and canvas contexts
123
+
124
+ ---
125
+
126
+ Current version: v1.0.0
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const w=require("react"),W=require("react/jsx-runtime"),L={word:"gv-word",line:"gv-line"},C={targetDensity:"auto",method:"letter-spacing",maxAdjustment:.05,calibrationFactor:2};function z(o){const r=getComputedStyle(o),s=r.fontWeight,a=r.fontSize,t=r.fontFamily.split(",")[0].replace(/['"]/g,"").trim();return`${s} ${a} ${t}`}function v(o,r,s,a,t){const i=t.getContext("2d");if(!i)return 0;t.width=Math.max(1,Math.ceil(s)),t.height=Math.max(1,Math.ceil(a)),i.clearRect(0,0,t.width,t.height),i.fillStyle="white",i.fillRect(0,0,t.width,t.height),i.fillStyle="black",i.font=r,i.fillText(o,0,t.height*.75);const c=i.getImageData(0,0,t.width,t.height).data;let p=0;const f=t.width*t.height;for(let u=0;u<c.length;u+=4){const A=c[u],b=c[u+1],y=c[u+2];(A<200||b<200||y<200)&&p++}return f>0?p/f:0}function G(o){const r=o.cloneNode(!0);return r.querySelectorAll(`.${L.word}, .${L.line}`).forEach(a=>{const t=a.parentNode;if(t){for(;a.firstChild;)t.insertBefore(a.firstChild,a);t.removeChild(a)}}),r.querySelectorAll("br[data-gv-break]").forEach(a=>a.remove()),r.innerHTML}function V(o,r,s={},a){if(typeof window>"u")return;const t=window.scrollY,i=s.targetDensity??C.targetDensity,d=s.method??C.method,c=s.maxAdjustment??C.maxAdjustment,p=s.calibrationFactor??C.calibrationFactor;if(o.innerHTML=r,!o.offsetWidth){requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})});return}const f=o.offsetWidth,u=parseFloat(getComputedStyle(o).fontSize)||16,A=z(o),b=[];(function n(e){e.nodeType===Node.TEXT_NODE?b.push(e):e.childNodes.forEach(n)})(o);const y=[];for(const n of b){const e=n.textContent??"";if(!e.trim())continue;const l=e.split(/(\S+)/),D=document.createDocumentFragment();for(let h=0;h<l.length;h+=2){const N=l[h],S=l[h+1];if(!S)continue;const M=l[h+3]===void 0?l[h+2]??"":"",x=document.createElement("span");x.className=L.word,x.style.cssText="display:inline-block;white-space:nowrap;",x.appendChild(document.createTextNode(N+S+M)),D.appendChild(x),y.push(x)}n.parentNode.replaceChild(D,n)}if(y.length===0){requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})});return}const m=[];let F=null,g=null;for(const n of y){const e=n.getBoundingClientRect(),l=Math.round(e.top);F===null||l!==F?(F=l,g={text:"",width:e.width,height:e.height||u,spans:[n]},m.push(g)):(g.width+=e.width,e.height>g.height&&(g.height=e.height),g.spans.push(n))}for(const n of m)n.text=n.spans.map(e=>e.textContent??"").join("").trim();if(m.length===0){requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})});return}const k=a??document.createElement("canvas"),T=m.map(n=>v(n.text,A,f,n.height||u,k));let E;if(typeof i=="number")E=i;else{const n=T.reduce((e,l)=>e+l,0);E=T.length>0?n/T.length:0}const Y=T.map(n=>{const e=(E-n)*p;return Math.max(-c,Math.min(c,e))});o.innerHTML=r;const H="display:inline-block;white-space:nowrap;vertical-align:top;";let R="";m.forEach((n,e)=>{const l=Y[e],h=`${H}${d==="word-spacing"?"word-spacing":"letter-spacing"}:${l}em;`,N=n.spans.map((S,j)=>{const M=S.textContent??"";return j===0?M.replace(/^[^\S\u00a0]+/,""):M}).join("");R+=`<span class="${L.line}" style="${h}">${N}</span>`,e<m.length-1&&(R+='<br data-gv-break="1">')}),o.innerHTML=R,requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})})}function P(o,r){o.innerHTML=r}function $(o){const r=w.useRef(null),s=w.useRef(null),a=w.useRef(o);a.current=o;const t=w.useCallback(()=>{const i=r.current;i&&(s.current===null&&(s.current=G(i)),V(i,s.current,a.current))},[]);return w.useLayoutEffect(()=>{t();let i=0,d=0;const c=new ResizeObserver(p=>{const f=Math.round(p[0].contentRect.width);f!==i&&(i=f,cancelAnimationFrame(d),d=requestAnimationFrame(t))});return c.observe(r.current),()=>{c.disconnect(),cancelAnimationFrame(d)}},[t]),r}const q=w.forwardRef(function({children:r,className:s,style:a,as:t="p",...i},d){const c=$(i);return W.jsx(t,{ref:c,className:s,style:a,children:r})});q.displayName="GrayValueText";exports.GrayValueText=q;exports.applyGrayValue=V;exports.getCleanHTML=G;exports.measureLineDensity=v;exports.removeGrayValue=P;exports.useGrayValue=$;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const v=require("react"),U=require("react/jsx-runtime"),D={word:"gv-word",line:"gv-line"};let E=null,k=!1;function X(){E!==null||k||(k=!0,Promise.resolve().then(()=>require("./steadygray-BcPniuRQ.cjs")).then(t=>{E=t}).catch(()=>{console.warn("[steadygray] canvas lineDetection requires @chenglou/pretext — falling back to BCR")}))}const G=new WeakMap;function J(t){const o=getComputedStyle(t),s=parseFloat(o.lineHeight);return isNaN(s)?parseFloat(o.fontSize)*1.2:s}const A={targetDensity:"auto",method:"letter-spacing",maxAdjustment:.05,tolerance:.01,calibrationFactor:2};function P(t){const o=getComputedStyle(t),s=o.fontWeight,c=o.fontSize,n=o.fontFamily.split(",")[0].replace(/['"]/g,"").trim();return`${s} ${c} ${n}`}function V(t,o,s,c,n){const u=n.getContext("2d",{willReadFrequently:!0});if(!u)return 0;const f=typeof window<"u"&&window.devicePixelRatio||1;n.width=Math.max(1,Math.ceil(s*f)),n.height=Math.max(1,Math.ceil(c*f)),u.setTransform(f,0,0,f,0,0),u.clearRect(0,0,s,c),u.fillStyle="white",u.fillRect(0,0,s,c),u.fillStyle="black",u.font=o,u.fillText(t,0,c*.75);const p=u.getImageData(0,0,n.width,n.height).data;let g=0;const m=n.width*n.height;for(let l=0;l<p.length;l+=4){const S=p[l],L=p[l+1],M=p[l+2];(S<140||L<140||M<140)&&g++}return m>0?g/m:0}function W(t){const o=t.cloneNode(!0);return o.querySelectorAll(`.${D.word}, .${D.line}`).forEach(c=>{const n=c.parentNode;if(n){for(;c.firstChild;)n.insertBefore(c.firstChild,c);n.removeChild(c)}}),o.querySelectorAll("br[data-gv-break]").forEach(c=>c.remove()),o.innerHTML}function Y(t,o,s={},c){var q;if(typeof window>"u")return;const n=window.scrollY,u=s.targetDensity??A.targetDensity,f=s.method??A.method,b=s.maxAdjustment??A.maxAdjustment,p=s.tolerance??A.tolerance,g=s.calibrationFactor??A.calibrationFactor,m=s.linePreservation??"none";if(t.innerHTML=o,!t.offsetWidth){requestAnimationFrame(()=>{Math.abs(window.scrollY-n)>2&&window.scrollTo({top:n,behavior:"instant"})});return}const l=t.offsetWidth,S=parseFloat(getComputedStyle(t).fontSize)||16,L=P(t),M=[];(function r(e){e.nodeType===Node.TEXT_NODE?M.push(e):e.childNodes.forEach(r)})(t);const d=[];for(const r of M){const e=r.textContent??"";if(!e.trim())continue;const i=e.split(/(\S+)/),h=document.createDocumentFragment();for(let a=0;a<i.length;a+=2){const y=i[a],C=i[a+1];if(!C)continue;const x=i[a+3]===void 0?i[a+2]??"":"",T=document.createElement("span");T.className=D.word,T.style.cssText="display:inline-block;white-space:nowrap;",T.appendChild(document.createTextNode(y+C+x)),h.appendChild(T),d.push(T)}r.parentNode.replaceChild(h,r)}if(d.length===0){requestAnimationFrame(()=>{Math.abs(window.scrollY-n)>2&&window.scrollTo({top:n,behavior:"instant"})});return}const j=s.lineDetection??"bcr";j==="canvas"&&X();const _=j==="canvas"&&E!==null,w=[];if(_){const r=G.get(t);let e;r&&r.originalHTML===o?e=r.prepared:(e=E.prepareWithSegments(t.textContent??"",P(t)),G.set(t,{originalHTML:o,prepared:e}));const i=J(t),{lines:h}=E.layoutWithLines(e,l,i);let a=0;for(const y of h){const C=y.text.replace(/\s+/g," ").trim(),F=[];let x="";for(;a<d.length;){const T=(d[a].textContent??"").replace(/\s+/g," ").trim();if(x=x?x+" "+T:T,F.push(d[a]),a++,x===C)break}F.length>0&&w.push({text:y.text.trim(),width:y.width,height:i,spans:F})}for(;a<d.length;)(q=w[w.length-1])==null||q.spans.push(d[a++]);for(const y of w)y.text=y.spans.map(C=>C.textContent??"").join("").trim()}else{let r=null,e=null;for(const i of d){const h=i.getBoundingClientRect(),a=Math.round(h.top);r===null||a!==r?(r=a,e={text:"",width:h.width,height:h.height||S,spans:[i]},w.push(e)):(e.width+=h.width,h.height>e.height&&(e.height=h.height),e.spans.push(i))}for(const i of w)i.text=i.spans.map(h=>h.textContent??"").join("").trim()}if(w.length===0){requestAnimationFrame(()=>{Math.abs(window.scrollY-n)>2&&window.scrollTo({top:n,behavior:"instant"})});return}const O=c??document.createElement("canvas"),N=w.map(r=>V(r.text,L,l,r.height||S,O));let R;if(typeof u=="number")R=u;else{const r=N.reduce((e,i)=>e+i,0);R=N.length>0?r/N.length:0}const B=N.map(r=>{const e=(R-r)*g;return Math.abs(e)<p?0:Math.max(-b,Math.min(b,e))});t.innerHTML=o;const I="display:inline-block;white-space:nowrap;vertical-align:top;";let $="";if(w.forEach((r,e)=>{const i=B[e],a=`${I}${f==="word-spacing"?"word-spacing":"letter-spacing"}:${i}em;`,y=r.spans.map((C,F)=>{const x=C.textContent??"";return F===0?x.replace(/^[^\S\u00a0]+/,""):x}).join("");$+=`<span class="${D.line}" style="${a}">${y}</span>`,e<w.length-1&&($+='<br data-gv-break="1">')}),t.innerHTML=$,m==="scale"){const r=Array.from(t.querySelectorAll(`.${D.line}`)),e=r.map(i=>i.getBoundingClientRect().width);r.forEach((i,h)=>{const a=e[h];a>.5&&Math.abs(a-l)>.5&&(i.style.width=`${l}px`,i.style.transform=`scaleX(${(l/a).toFixed(6)})`,i.style.transformOrigin="left center")})}requestAnimationFrame(()=>{Math.abs(window.scrollY-n)>2&&window.scrollTo({top:n,behavior:"instant"})})}function K(t,o){t.innerHTML=o}function z(t){const o=v.useRef(null),s=v.useRef(null),c=v.useRef(t);c.current=t;const{maxAdjustment:n,calibrationFactor:u,method:f,tolerance:b,targetDensity:p,lineDetection:g}=t,m=v.useCallback(()=>{const l=o.current;l&&(s.current===null&&(s.current=W(l)),Y(l,s.current,c.current))},[n,u,f,b,p,g]);return v.useLayoutEffect(()=>{m();let l=0,S=0;const L=new ResizeObserver(M=>{const d=Math.round(M[0].contentRect.width);d!==l&&(l=d,cancelAnimationFrame(S),S=requestAnimationFrame(m))});return L.observe(o.current),()=>{L.disconnect(),cancelAnimationFrame(S)}},[m]),v.useEffect(()=>{document.fonts.ready.then(m)},[m]),o}const H=v.forwardRef(function({children:o,className:s,style:c,as:n="p",...u},f){const b=z(u),p=v.useCallback(g=>{b.current=g,typeof f=="function"?f(g):f&&(f.current=g)},[b,f]);return U.jsx(n,{ref:p,className:s,style:c,children:o})});H.displayName="GrayValueText";exports.GrayValueText=H;exports.applyGrayValue=Y;exports.getCleanHTML=W;exports.measureLineDensity=V;exports.removeGrayValue=K;exports.useGrayValue=z;
package/dist/index.d.ts CHANGED
@@ -34,6 +34,18 @@ export declare function getCleanHTML(el: HTMLElement): string;
34
34
 
35
35
  /** Options controlling the gray-value density-equalization algorithm */
36
36
  export declare interface GrayValueOptions {
37
+ /**
38
+ * Line detection method. Default: 'bcr'
39
+ *
40
+ * - **'bcr'** (default) — uses `getBoundingClientRect()` on injected word spans.
41
+ * Ground truth: reads actual browser layout, handles all inline HTML and any font.
42
+ *
43
+ * - **'canvas'** — uses `@chenglou/pretext` canvas measurement for arithmetic line
44
+ * breaking. No forced reflow on resize. Requires `@chenglou/pretext` to be installed.
45
+ * Falls back to 'bcr' on the first render while pretext loads.
46
+ * Avoid with `system-ui` font (canvas resolves differently on macOS).
47
+ */
48
+ lineDetection?: 'bcr' | 'canvas';
37
49
  /**
38
50
  * Target optical density ratio (0–1).
39
51
  * 'auto' = average of all measured line densities (default).
@@ -61,6 +73,22 @@ export declare interface GrayValueOptions {
61
73
  * Default: 2.0
62
74
  */
63
75
  calibrationFactor?: number;
76
+ /**
77
+ * Line width preservation strategy after the spacing correction is applied. Default: 'none'
78
+ *
79
+ * Density equalization adjusts letter-spacing per line, which alters each line's visual width.
80
+ * Lines that receive positive spacing grow wider than the container; lines that receive
81
+ * negative spacing leave a gap. Both are bounded by `maxAdjustment`.
82
+ *
83
+ * - **'none'** (default) — no compensation. Line widths vary up to ±maxAdjustment × charCount.
84
+ * Suitable when `maxAdjustment` is small (≤ 0.05em) and slight overflow is acceptable.
85
+ *
86
+ * - **'scale'** — after applying spacing, a CSS `scaleX` transform is added to each line so it
87
+ * occupies exactly its original width. The density correction remains visible as a change in
88
+ * glyph spacing ratio, but lines never overflow the container. Slightly alters glyph
89
+ * proportions at large correction values.
90
+ */
91
+ linePreservation?: 'none' | 'scale';
64
92
  }
65
93
 
66
94
  /**
package/dist/index.js CHANGED
@@ -1,161 +1,218 @@
1
- import { useRef as R, useCallback as Y, useLayoutEffect as q, forwardRef as G } from "react";
2
- import { jsx as V } from "react/jsx-runtime";
3
- const A = {
1
+ import { useRef as R, useCallback as Y, useLayoutEffect as H, useEffect as B, forwardRef as O } from "react";
2
+ import { jsx as I } from "react/jsx-runtime";
3
+ const M = {
4
4
  word: "gv-word",
5
5
  line: "gv-line"
6
- }, M = {
6
+ };
7
+ let D = null, q = !1;
8
+ function U() {
9
+ D !== null || q || (q = !0, import("./steadygray-DjWw6Iqb.js").then((t) => {
10
+ D = t;
11
+ }).catch(() => {
12
+ console.warn("[steadygray] canvas lineDetection requires @chenglou/pretext — falling back to BCR");
13
+ }));
14
+ }
15
+ const P = /* @__PURE__ */ new WeakMap();
16
+ function X(t) {
17
+ const o = getComputedStyle(t), s = parseFloat(o.lineHeight);
18
+ return isNaN(s) ? parseFloat(o.fontSize) * 1.2 : s;
19
+ }
20
+ const L = {
7
21
  targetDensity: "auto",
8
22
  method: "letter-spacing",
9
23
  maxAdjustment: 0.05,
24
+ tolerance: 0.01,
10
25
  calibrationFactor: 2
11
26
  };
12
- function W(o) {
13
- const i = getComputedStyle(o), a = i.fontWeight, s = i.fontSize, t = i.fontFamily.split(",")[0].replace(/['"]/g, "").trim();
14
- return `${a} ${s} ${t}`;
27
+ function W(t) {
28
+ const o = getComputedStyle(t), s = o.fontWeight, c = o.fontSize, n = o.fontFamily.split(",")[0].replace(/['"]/g, "").trim();
29
+ return `${s} ${c} ${n}`;
15
30
  }
16
- function H(o, i, a, s, t) {
17
- const r = t.getContext("2d");
18
- if (!r) return 0;
19
- t.width = Math.max(1, Math.ceil(a)), t.height = Math.max(1, Math.ceil(s)), r.clearRect(0, 0, t.width, t.height), r.fillStyle = "white", r.fillRect(0, 0, t.width, t.height), r.fillStyle = "black", r.font = i, r.fillText(o, 0, t.height * 0.75);
20
- const c = r.getImageData(0, 0, t.width, t.height).data;
21
- let p = 0;
22
- const f = t.width * t.height;
23
- for (let h = 0; h < c.length; h += 4) {
24
- const C = c[h], x = c[h + 1], w = c[h + 2];
25
- (C < 200 || x < 200 || w < 200) && p++;
31
+ function J(t, o, s, c, n) {
32
+ const u = n.getContext("2d", { willReadFrequently: !0 });
33
+ if (!u) return 0;
34
+ const f = typeof window < "u" && window.devicePixelRatio || 1;
35
+ n.width = Math.max(1, Math.ceil(s * f)), n.height = Math.max(1, Math.ceil(c * f)), u.setTransform(f, 0, 0, f, 0, 0), u.clearRect(0, 0, s, c), u.fillStyle = "white", u.fillRect(0, 0, s, c), u.fillStyle = "black", u.font = o, u.fillText(t, 0, c * 0.75);
36
+ const p = u.getImageData(0, 0, n.width, n.height).data;
37
+ let m = 0;
38
+ const g = n.width * n.height;
39
+ for (let l = 0; l < p.length; l += 4) {
40
+ const S = p[l], v = p[l + 1], F = p[l + 2];
41
+ (S < 140 || v < 140 || F < 140) && m++;
26
42
  }
27
- return f > 0 ? p / f : 0;
43
+ return g > 0 ? m / g : 0;
28
44
  }
29
- function z(o) {
30
- const i = o.cloneNode(!0);
31
- return i.querySelectorAll(
32
- `.${A.word}, .${A.line}`
33
- ).forEach((s) => {
34
- const t = s.parentNode;
35
- if (t) {
36
- for (; s.firstChild; ) t.insertBefore(s.firstChild, s);
37
- t.removeChild(s);
45
+ function K(t) {
46
+ const o = t.cloneNode(!0);
47
+ return o.querySelectorAll(
48
+ `.${M.word}, .${M.line}`
49
+ ).forEach((c) => {
50
+ const n = c.parentNode;
51
+ if (n) {
52
+ for (; c.firstChild; ) n.insertBefore(c.firstChild, c);
53
+ n.removeChild(c);
38
54
  }
39
- }), i.querySelectorAll("br[data-gv-break]").forEach((s) => s.remove()), i.innerHTML;
55
+ }), o.querySelectorAll("br[data-gv-break]").forEach((c) => c.remove()), o.innerHTML;
40
56
  }
41
- function _(o, i, a = {}, s) {
57
+ function Q(t, o, s = {}, c) {
58
+ var k;
42
59
  if (typeof window > "u") return;
43
- const t = window.scrollY, r = a.targetDensity ?? M.targetDensity, d = a.method ?? M.method, c = a.maxAdjustment ?? M.maxAdjustment, p = a.calibrationFactor ?? M.calibrationFactor;
44
- if (o.innerHTML = i, !o.offsetWidth) {
60
+ const n = window.scrollY, u = s.targetDensity ?? L.targetDensity, f = s.method ?? L.method, b = s.maxAdjustment ?? L.maxAdjustment, p = s.tolerance ?? L.tolerance, m = s.calibrationFactor ?? L.calibrationFactor, g = s.linePreservation ?? "none";
61
+ if (t.innerHTML = o, !t.offsetWidth) {
45
62
  requestAnimationFrame(() => {
46
- Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
63
+ Math.abs(window.scrollY - n) > 2 && window.scrollTo({ top: n, behavior: "instant" });
47
64
  });
48
65
  return;
49
66
  }
50
- const f = o.offsetWidth, h = parseFloat(getComputedStyle(o).fontSize) || 16, C = W(o), x = [];
51
- (function n(e) {
52
- e.nodeType === Node.TEXT_NODE ? x.push(e) : e.childNodes.forEach(n);
53
- })(o);
54
- const w = [];
55
- for (const n of x) {
56
- const e = n.textContent ?? "";
67
+ const l = t.offsetWidth, S = parseFloat(getComputedStyle(t).fontSize) || 16, v = W(t), F = [];
68
+ (function r(e) {
69
+ e.nodeType === Node.TEXT_NODE ? F.push(e) : e.childNodes.forEach(r);
70
+ })(t);
71
+ const d = [];
72
+ for (const r of F) {
73
+ const e = r.textContent ?? "";
57
74
  if (!e.trim()) continue;
58
- const l = e.split(/(\S+)/), N = document.createDocumentFragment();
59
- for (let u = 0; u < l.length; u += 2) {
60
- const D = l[u], T = l[u + 1];
61
- if (!T) continue;
62
- const S = l[u + 3] === void 0 ? l[u + 2] ?? "" : "", y = document.createElement("span");
63
- y.className = A.word, y.style.cssText = "display:inline-block;white-space:nowrap;", y.appendChild(document.createTextNode(D + T + S)), N.appendChild(y), w.push(y);
75
+ const i = e.split(/(\S+)/), h = document.createDocumentFragment();
76
+ for (let a = 0; a < i.length; a += 2) {
77
+ const y = i[a], C = i[a + 1];
78
+ if (!C) continue;
79
+ const x = i[a + 3] === void 0 ? i[a + 2] ?? "" : "", T = document.createElement("span");
80
+ T.className = M.word, T.style.cssText = "display:inline-block;white-space:nowrap;", T.appendChild(document.createTextNode(y + C + x)), h.appendChild(T), d.push(T);
64
81
  }
65
- n.parentNode.replaceChild(N, n);
82
+ r.parentNode.replaceChild(h, r);
66
83
  }
67
- if (w.length === 0) {
84
+ if (d.length === 0) {
68
85
  requestAnimationFrame(() => {
69
- Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
86
+ Math.abs(window.scrollY - n) > 2 && window.scrollTo({ top: n, behavior: "instant" });
70
87
  });
71
88
  return;
72
89
  }
73
- const m = [];
74
- let F = null, g = null;
75
- for (const n of w) {
76
- const e = n.getBoundingClientRect(), l = Math.round(e.top);
77
- F === null || l !== F ? (F = l, g = {
78
- text: "",
79
- width: e.width,
80
- height: e.height || h,
81
- spans: [n]
82
- }, m.push(g)) : (g.width += e.width, e.height > g.height && (g.height = e.height), g.spans.push(n));
90
+ const j = s.lineDetection ?? "bcr";
91
+ j === "canvas" && U();
92
+ const G = j === "canvas" && D !== null, w = [];
93
+ if (G) {
94
+ const r = P.get(t);
95
+ let e;
96
+ r && r.originalHTML === o ? e = r.prepared : (e = D.prepareWithSegments(t.textContent ?? "", W(t)), P.set(t, { originalHTML: o, prepared: e }));
97
+ const i = X(t), { lines: h } = D.layoutWithLines(e, l, i);
98
+ let a = 0;
99
+ for (const y of h) {
100
+ const C = y.text.replace(/\s+/g, " ").trim(), A = [];
101
+ let x = "";
102
+ for (; a < d.length; ) {
103
+ const T = (d[a].textContent ?? "").replace(/\s+/g, " ").trim();
104
+ if (x = x ? x + " " + T : T, A.push(d[a]), a++, x === C) break;
105
+ }
106
+ A.length > 0 && w.push({ text: y.text.trim(), width: y.width, height: i, spans: A });
107
+ }
108
+ for (; a < d.length; )
109
+ (k = w[w.length - 1]) == null || k.spans.push(d[a++]);
110
+ for (const y of w)
111
+ y.text = y.spans.map((C) => C.textContent ?? "").join("").trim();
112
+ } else {
113
+ let r = null, e = null;
114
+ for (const i of d) {
115
+ const h = i.getBoundingClientRect(), a = Math.round(h.top);
116
+ r === null || a !== r ? (r = a, e = {
117
+ text: "",
118
+ width: h.width,
119
+ height: h.height || S,
120
+ spans: [i]
121
+ }, w.push(e)) : (e.width += h.width, h.height > e.height && (e.height = h.height), e.spans.push(i));
122
+ }
123
+ for (const i of w)
124
+ i.text = i.spans.map((h) => h.textContent ?? "").join("").trim();
83
125
  }
84
- for (const n of m)
85
- n.text = n.spans.map((e) => e.textContent ?? "").join("").trim();
86
- if (m.length === 0) {
126
+ if (w.length === 0) {
87
127
  requestAnimationFrame(() => {
88
- Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
128
+ Math.abs(window.scrollY - n) > 2 && window.scrollTo({ top: n, behavior: "instant" });
89
129
  });
90
130
  return;
91
131
  }
92
- const j = s ?? document.createElement("canvas"), b = m.map(
93
- (n) => H(
94
- n.text,
95
- C,
96
- f,
97
- n.height || h,
98
- j
132
+ const V = c ?? document.createElement("canvas"), E = w.map(
133
+ (r) => J(
134
+ r.text,
135
+ v,
136
+ l,
137
+ r.height || S,
138
+ V
99
139
  )
100
140
  );
101
- let L;
102
- if (typeof r == "number")
103
- L = r;
141
+ let N;
142
+ if (typeof u == "number")
143
+ N = u;
104
144
  else {
105
- const n = b.reduce((e, l) => e + l, 0);
106
- L = b.length > 0 ? n / b.length : 0;
145
+ const r = E.reduce((e, i) => e + i, 0);
146
+ N = E.length > 0 ? r / E.length : 0;
107
147
  }
108
- const v = b.map((n) => {
109
- const e = (L - n) * p;
110
- return Math.max(-c, Math.min(c, e));
148
+ const z = E.map((r) => {
149
+ const e = (N - r) * m;
150
+ return Math.abs(e) < p ? 0 : Math.max(-b, Math.min(b, e));
111
151
  });
112
- o.innerHTML = i;
113
- const k = "display:inline-block;white-space:nowrap;vertical-align:top;";
114
- let E = "";
115
- m.forEach((n, e) => {
116
- const l = v[e], u = `${k}${d === "word-spacing" ? "word-spacing" : "letter-spacing"}:${l}em;`, D = n.spans.map((T, $) => {
117
- const S = T.textContent ?? "";
118
- return $ === 0 ? S.replace(/^[^\S\u00a0]+/, "") : S;
152
+ t.innerHTML = o;
153
+ const _ = "display:inline-block;white-space:nowrap;vertical-align:top;";
154
+ let $ = "";
155
+ if (w.forEach((r, e) => {
156
+ const i = z[e], a = `${_}${f === "word-spacing" ? "word-spacing" : "letter-spacing"}:${i}em;`, y = r.spans.map((C, A) => {
157
+ const x = C.textContent ?? "";
158
+ return A === 0 ? x.replace(/^[^\S\u00a0]+/, "") : x;
119
159
  }).join("");
120
- E += `<span class="${A.line}" style="${u}">${D}</span>`, e < m.length - 1 && (E += '<br data-gv-break="1">');
121
- }), o.innerHTML = E, requestAnimationFrame(() => {
122
- Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
160
+ $ += `<span class="${M.line}" style="${a}">${y}</span>`, e < w.length - 1 && ($ += '<br data-gv-break="1">');
161
+ }), t.innerHTML = $, g === "scale") {
162
+ const r = Array.from(
163
+ t.querySelectorAll(`.${M.line}`)
164
+ ), e = r.map((i) => i.getBoundingClientRect().width);
165
+ r.forEach((i, h) => {
166
+ const a = e[h];
167
+ a > 0.5 && Math.abs(a - l) > 0.5 && (i.style.width = `${l}px`, i.style.transform = `scaleX(${(l / a).toFixed(6)})`, i.style.transformOrigin = "left center");
168
+ });
169
+ }
170
+ requestAnimationFrame(() => {
171
+ Math.abs(window.scrollY - n) > 2 && window.scrollTo({ top: n, behavior: "instant" });
123
172
  });
124
173
  }
125
- function U(o, i) {
126
- o.innerHTML = i;
174
+ function ot(t, o) {
175
+ t.innerHTML = o;
127
176
  }
128
- function P(o) {
129
- const i = R(null), a = R(null), s = R(o);
130
- s.current = o;
131
- const t = Y(() => {
132
- const r = i.current;
133
- r && (a.current === null && (a.current = z(r)), _(r, a.current, s.current));
134
- }, []);
135
- return q(() => {
136
- t();
137
- let r = 0, d = 0;
138
- const c = new ResizeObserver((p) => {
139
- const f = Math.round(p[0].contentRect.width);
140
- f !== r && (r = f, cancelAnimationFrame(d), d = requestAnimationFrame(t));
177
+ function Z(t) {
178
+ const o = R(null), s = R(null), c = R(t);
179
+ c.current = t;
180
+ const { maxAdjustment: n, calibrationFactor: u, method: f, tolerance: b, targetDensity: p, lineDetection: m } = t, g = Y(() => {
181
+ const l = o.current;
182
+ l && (s.current === null && (s.current = K(l)), Q(l, s.current, c.current));
183
+ }, [n, u, f, b, p, m]);
184
+ return H(() => {
185
+ g();
186
+ let l = 0, S = 0;
187
+ const v = new ResizeObserver((F) => {
188
+ const d = Math.round(F[0].contentRect.width);
189
+ d !== l && (l = d, cancelAnimationFrame(S), S = requestAnimationFrame(g));
141
190
  });
142
- return c.observe(i.current), () => {
143
- c.disconnect(), cancelAnimationFrame(d);
191
+ return v.observe(o.current), () => {
192
+ v.disconnect(), cancelAnimationFrame(S);
144
193
  };
145
- }, [t]), i;
194
+ }, [g]), B(() => {
195
+ document.fonts.ready.then(g);
196
+ }, [g]), o;
146
197
  }
147
- const I = G(
148
- function({ children: i, className: a, style: s, as: t = "p", ...r }, d) {
149
- const c = P(r);
150
- return /* @__PURE__ */ V(t, { ref: c, className: a, style: s, children: i });
198
+ const tt = O(
199
+ function({ children: o, className: s, style: c, as: n = "p", ...u }, f) {
200
+ const b = Z(u), p = Y(
201
+ (m) => {
202
+ b.current = m, typeof f == "function" ? f(m) : f && (f.current = m);
203
+ },
204
+ // eslint-disable-next-line react-hooks/exhaustive-deps
205
+ [b, f]
206
+ );
207
+ return /* @__PURE__ */ I(n, { ref: p, className: s, style: c, children: o });
151
208
  }
152
209
  );
153
- I.displayName = "GrayValueText";
210
+ tt.displayName = "GrayValueText";
154
211
  export {
155
- I as GrayValueText,
156
- _ as applyGrayValue,
157
- z as getCleanHTML,
158
- H as measureLineDensity,
159
- U as removeGrayValue,
160
- P as useGrayValue
212
+ tt as GrayValueText,
213
+ Q as applyGrayValue,
214
+ K as getCleanHTML,
215
+ J as measureLineDensity,
216
+ ot as removeGrayValue,
217
+ Z as useGrayValue
161
218
  };
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e={};exports.default=e;
@@ -0,0 +1,4 @@
1
+ const a = {};
2
+ export {
3
+ a as default
4
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liiift-studio/steadygray",
3
- "version": "0.0.1",
4
- "description": "Paragraph optical density equalization measure actual glyph area per line and equalize visual density across all lines",
3
+ "version": "1.0.0",
4
+ "description": "Paragraph optical density equalization \u2014 measure actual glyph area per line and equalize visual density across all lines",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.js",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": ">=17",
28
- "react-dom": ">=17"
28
+ "react-dom": ">=17",
29
+ "@chenglou/pretext": ">=0.0.5"
29
30
  },
30
31
  "peerDependenciesMeta": {
31
32
  "react": {
@@ -33,6 +34,9 @@
33
34
  },
34
35
  "react-dom": {
35
36
  "optional": true
37
+ },
38
+ "@chenglou/pretext": {
39
+ "optional": true
36
40
  }
37
41
  },
38
42
  "devDependencies": {
@@ -50,9 +54,14 @@
50
54
  "typography",
51
55
  "gray-value",
52
56
  "optical-density",
57
+ "glyph-area",
58
+ "canvas",
59
+ "letter-spacing",
53
60
  "typesetting",
61
+ "zero-dependencies",
54
62
  "react",
55
- "typescript"
63
+ "typescript",
64
+ "css"
56
65
  ],
57
66
  "author": "Quinn Keaveney <quinn@liiift.studio>",
58
67
  "license": "MIT",
@@ -64,5 +73,8 @@
64
73
  "bugs": {
65
74
  "url": "https://github.com/Liiift-Studio/SteadyGray/issues"
66
75
  },
67
- "sideEffects": false
76
+ "sideEffects": false,
77
+ "publishConfig": {
78
+ "access": "public"
79
+ }
68
80
  }