@weasel-js/font 1.0.3 → 1.1.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/dist/index.js CHANGED
@@ -32,6 +32,10 @@ function parseFontStyle(style) {
32
32
  const weight = WEIGHT_WORDS.find(([word]) => s.includes(word))?.[1] ?? 400;
33
33
  return { weight, style: italic ? "italic" : "normal" };
34
34
  }
35
+ function styleQualifierCount(style) {
36
+ const words = style.toLowerCase().split(/[\s_-]+/).filter(Boolean);
37
+ return words.filter((w) => w !== "italic" && w !== "oblique" && !WEIGHT_WORDS.some(([word]) => word === w)).length;
38
+ }
35
39
  async function enableLocalFontOutlines(opts = {}) {
36
40
  const query = globalThis.queryLocalFonts;
37
41
  if (typeof query !== "function") {
@@ -41,20 +45,29 @@ async function enableLocalFontOutlines(opts = {}) {
41
45
  }
42
46
  const wanted = opts.families ? new Set(opts.families) : null;
43
47
  const data = await query.call(globalThis);
44
- const families = /* @__PURE__ */ new Set();
45
- let faces = 0;
48
+ const chosen = /* @__PURE__ */ new Map();
46
49
  for (const font of data) {
47
50
  if (wanted && !wanted.has(font.family)) continue;
48
- const variant = parseFontStyle(font.style);
49
- registerFontOutlines(font.family, variant, () => font.blob(), {
51
+ const { weight, style } = parseFontStyle(font.style);
52
+ const key = `${font.family}|${weight}|${style}`;
53
+ const qualifiers = styleQualifierCount(font.style);
54
+ const prev = chosen.get(key);
55
+ if (prev && prev.qualifiers <= qualifiers) continue;
56
+ chosen.set(key, { font, qualifiers });
57
+ }
58
+ const families = /* @__PURE__ */ new Set();
59
+ for (const { font } of chosen.values()) {
60
+ registerFontOutlines(font.family, parseFontStyle(font.style), () => font.blob(), {
50
61
  // The PostScript name is how a member is picked out of a `.ttc`
51
62
  // collection, which is what most macOS system families ship as.
52
63
  parser: createOpenTypeParser(font.postscriptName)
53
64
  });
54
65
  families.add(font.family);
55
- faces++;
56
66
  }
57
- return { families: [...families].sort((a, b) => a.localeCompare(b)), faces };
67
+ return {
68
+ families: [...families].sort((a, b) => a.localeCompare(b)),
69
+ faces: chosen.size
70
+ };
58
71
  }
59
72
 
60
73
  // src/textSdf.ts
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/outline/localFonts.ts","../src/textSdf.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Local Font Access → outline registrations.\n *\n * The dynamic canvas-SDF tier can render any installed family without ever\n * seeing its bytes: it asks canvas 2D to `fillText` and takes the pixels. The\n * outline tier cannot — it needs the actual font file — and the only web API\n * that hands one over is `window.queryLocalFonts()`, which is Chromium-only\n * and gated behind a permission prompt that requires a user gesture.\n *\n * So this is the one part of the tier a consumer has to *ask* for, from a\n * click. Everything downstream treats a refusal as ordinary: no\n * registrations, `glyphOutline` keeps answering `null`, and large text keeps\n * rendering from the SDF tier exactly as it does today. That ladder is\n * load-bearing rather than defensive — Safari and Firefox have no equivalent\n * API at all, so \"denied\" is the permanent state for most of the web.\n *\n * Registration is eager but reading is lazy: every matching face gets a thunk\n * that calls `FontData.blob()` at first glyph request. A machine can easily\n * have hundreds of faces and a `.ttc` runs to tens of megabytes, so\n * downloading them all at enable time would be worse than not offering the\n * tier.\n */\n\nimport type { OutlineFontStyle } from './OutlineFace';\nimport { createOpenTypeParser } from './opentypeParser';\nimport { registerFontOutlines } from './outlineRegistry';\n\n/**\n * The slice of the Local Font Access API this module uses. Declared here\n * rather than pulled from a lib: `queryLocalFonts` is not in TypeScript's DOM\n * types, and the alternative — casting `window` to `any` at the call site —\n * would lose the shape of what comes back.\n */\ninterface LocalFontData {\n postscriptName: string;\n fullName: string;\n family: string;\n style: string;\n blob(): Promise<Blob>;\n}\n\ntype LocalFontWindow = typeof globalThis & {\n queryLocalFonts?: (opts?: { postscriptNames?: string[] }) => Promise<LocalFontData[]>;\n};\n\n/** Is the Local Font Access API present at all? False on every non-Chromium\n * browser and in any non-secure context. */\nexport function canQueryLocalFonts(): boolean {\n return typeof globalThis !== 'undefined'\n && typeof (globalThis as LocalFontWindow).queryLocalFonts === 'function';\n}\n\n/**\n * CSS weights for the weight words that appear in a `style` string.\n *\n * Ordered longest-first where one name contains another (\"ExtraBold\" before\n * \"Bold\", \"DemiBold\" before \"Bold\"), because the match is a substring scan\n * and \"Bold\" would otherwise claim every heavier face for 700.\n */\nconst WEIGHT_WORDS: readonly (readonly [string, number])[] = [\n ['extrablack', 950], ['ultrablack', 950],\n ['extrabold', 800], ['ultrabold', 800],\n ['extralight', 200], ['ultralight', 200],\n ['semibold', 600], ['demibold', 600],\n ['semilight', 350],\n ['black', 900], ['heavy', 900],\n ['bold', 700],\n ['medium', 500],\n ['light', 300],\n ['thin', 100], ['hairline', 100],\n ['book', 400], ['regular', 400], ['normal', 400],\n];\n\n/**\n * Parse a `FontData.style` string — \"Regular\", \"Bold Italic\", \"Condensed\n * ExtraLight Oblique\" — into the CSS variant the kit's registry is keyed by.\n *\n * Anything unrecognized lands on 400/normal, which is what the string\n * \"Regular\" would have produced anyway. A wrong guess here costs a face that\n * registers under a variant nothing asks for; it cannot mis-paint anything,\n * because resolution is an exact-match lookup that simply misses.\n */\nexport function parseFontStyle(style: string): { weight: number; style: OutlineFontStyle } {\n const s = style.toLowerCase();\n const italic = s.includes('italic') || s.includes('oblique');\n const weight = WEIGHT_WORDS.find(([word]) => s.includes(word))?.[1] ?? 400;\n return { weight, style: italic ? 'italic' : 'normal' };\n}\n\n/** Options for `registerLocalFontOutlines`. */\nexport interface LocalFontOutlinesOptions {\n /**\n * Restrict registration to these families. Defaults to every installed\n * family. Worth passing when the consumer already knows its font menu:\n * a machine with 900 faces otherwise gets 900 registry entries, all inert\n * but all held.\n */\n families?: readonly string[];\n}\n\n/** What `registerLocalFontOutlines` registered. */\nexport interface LocalFontOutlinesResult {\n /** Families that now have at least one outline face, in menu order. */\n families: readonly string[];\n /** Total faces registered across those families. */\n faces: number;\n}\n\n/**\n * Ask for permission to read installed fonts, and register outline sources\n * for the faces it returns. **Must be called from a user gesture** — the\n * permission prompt requires one, and without it the call rejects.\n *\n * Rejects when the API is missing or the user denies; both are ordinary\n * outcomes and neither leaves the registry in a partial state, so a caller\n * that simply ignores the rejection still has a working SDF tier.\n */\nexport async function enableLocalFontOutlines(\n opts: LocalFontOutlinesOptions = {},\n): Promise<LocalFontOutlinesResult> {\n const query = (globalThis as LocalFontWindow).queryLocalFonts;\n if (typeof query !== 'function') {\n throw new Error(\n 'weasel enableLocalFontOutlines: this browser has no Local Font Access API ' +\n '(queryLocalFonts). Large text keeps rendering from the SDF tier.',\n );\n }\n\n const wanted = opts.families ? new Set(opts.families) : null;\n const data = await query.call(globalThis);\n\n const families = new Set<string>();\n let faces = 0;\n for (const font of data) {\n if (wanted && !wanted.has(font.family)) continue;\n const variant = parseFontStyle(font.style);\n registerFontOutlines(font.family, variant, () => font.blob(), {\n // The PostScript name is how a member is picked out of a `.ttc`\n // collection, which is what most macOS system families ship as.\n parser: createOpenTypeParser(font.postscriptName),\n });\n families.add(font.family);\n faces++;\n }\n return { families: [...families].sort((a, b) => a.localeCompare(b)), faces };\n}\n","/**\n * GLSL ES 3.0 sources for the built-in MSDF text shader.\n *\n * Vertex inputs (interleaved, stride 20 bytes = 5 × float):\n * a_position vec2 screen-space x,y of the glyph quad vertex\n * a_uv vec2 atlas UV (0..1)\n * a_baselineY float line baseline Y in screen space (for synth-italic skew)\n *\n * Uniforms:\n * u_proj mat3 screen → clip projection\n * u_model mat3 cumulative group transform\n * u_atlas sampler2D the MSDF atlas texture (bound to TEXTURE0)\n * u_color vec4 text color (straight RGBA)\n * u_alpha float group alpha multiplier\n * u_colorMatrix mat4 color transform applied to u_color before alpha modulation\n * u_colorBias vec4 bias added after the matrix (identity = zero bias)\n *\n * Output: PREMULTIPLIED alpha — `vec4(color.rgb * a, a)` per conventions §2.\n * Blend func: ONE / ONE_MINUS_SRC_ALPHA.\n *\n * MSDF channel layout: msdf-bmfont-xml outputs R,G,B channels as independent\n * signed-distance fields covering different edge directions. The true SDF\n * value is the median of R,G,B; this recovers sharp outlines while averaging\n * out single-channel aliasing artifacts.\n *\n * Antialiasing (`aaWidth`, both shaders): the smoothstep band must be one\n * *screen* pixel wide, so it is derived per-fragment from `fwidth(sdfVal)` —\n * the rate the field changes between adjacent fragments. That single quantity\n * already folds in font size, zoom, and DPR: minify the glyph and the field\n * changes faster, so the band widens in field units to stay one pixel on\n * screen; magnify it and the band narrows.\n *\n * A *constant* band cannot be correct at more than one scale, and this shader\n * used one (0.05) until 2026-07-29. At 16px text the band collapsed to well\n * under a pixel and glyph edges quantized to hard stair-steps; at display\n * sizes the same constant read mushy. `fwidth` is core in GLSL ES 3.00, so\n * no extension guard is needed. The `max()` floor keeps a degenerate\n * derivative (flat field, or a driver returning 0) from producing a\n * zero-width band, which would be the aliased behavior all over again.\n */\n\nexport const TEXT_VERT_SRC = /* glsl */ `#version 300 es\nin vec2 a_position;\nin vec2 a_uv;\nin float a_baselineY;\nuniform mat3 u_proj;\nuniform mat3 u_model;\nuniform float u_synthItalic;\nout vec2 v_uv;\nvoid main() {\n // Synthetic italic: shift x by (a_baselineY - a_position.y) * tan(angle).\n // Above-baseline vertices (lower y in screen coords) lean further right.\n vec2 skewed = vec2(\n a_position.x + (a_baselineY - a_position.y) * tan(u_synthItalic),\n a_position.y\n );\n vec3 screen = u_model * vec3(skewed, 1.0);\n vec3 clip = u_proj * vec3(screen.xy, 1.0);\n gl_Position = vec4(clip.xy, 0.0, 1.0);\n v_uv = a_uv;\n}\n`;\n\n/** Fragment shader for the MSDF text program. Samples the atlas, applies the\n * color transform, and emits premultiplied alpha. */\nexport const TEXT_FRAG_SRC = /* glsl */ `#version 300 es\nprecision highp float;\nin vec2 v_uv;\nuniform sampler2D u_atlas;\nuniform vec4 u_color;\nuniform float u_alpha;\nuniform float u_synthBold;\nuniform mat4 u_colorMatrix;\nuniform vec4 u_colorBias;\nout vec4 outColor;\n\nfloat median(float r, float g, float b) {\n return max(min(r, g), min(max(r, g), b));\n}\n\nvoid main() {\n vec3 sdf = texture(u_atlas, v_uv).rgb;\n float sdfVal = median(sdf.r, sdf.g, sdf.b);\n // Screen-space AA band — see the file header. Half of fwidth spans ~1px.\n float aaW = max(0.5 * fwidth(sdfVal), 0.0005);\n // u_synthBold shifts the SDF threshold to thicken strokes when the\n // resolver fell back from a missing bold variant to the regular atlas.\n float threshold = 0.5 - u_synthBold;\n float msdfAlpha = smoothstep(threshold - aaW, threshold + aaW, sdfVal);\n vec4 src = vec4(u_color.rgb, u_color.a);\n vec4 mapped = clamp(u_colorMatrix * src + u_colorBias, 0.0, 1.0);\n float a = mapped.a * msdfAlpha * u_alpha;\n outColor = vec4(mapped.rgb * a, a);\n}\n`;\n\n/**\n * Single-channel sibling of TEXT_FRAG_SRC for runtime canvas-SDF glyphs\n * (DynamicGlyphAtlas R8 pages): the R channel IS the distance field, so no\n * median. Threshold semantics (0.5 edge, u_synthBold shift) match the MSDF\n * shader because the bake encodes the edge at ~128.\n *\n * Accepted trade: corner rounding away from the bake size, mildest near it.\n * `glyphRasterizer.ts` carries the measurements and the reason neither a\n * larger bake nor extra taps would improve the small-text end.\n */\nexport const TEXT_FRAG_R8_SRC = /* glsl */ `#version 300 es\nprecision highp float;\nin vec2 v_uv;\nuniform sampler2D u_atlas;\nuniform vec4 u_color;\nuniform float u_alpha;\nuniform float u_synthBold;\nuniform mat4 u_colorMatrix;\nuniform vec4 u_colorBias;\nout vec4 outColor;\n\nvoid main() {\n float sdfVal = texture(u_atlas, v_uv).r;\n // Screen-space AA band — see the file header. Half of fwidth spans ~1px.\n float aaW = max(0.5 * fwidth(sdfVal), 0.0005);\n float threshold = 0.5 - u_synthBold;\n float sdfAlpha = smoothstep(threshold - aaW, threshold + aaW, sdfVal);\n vec4 src = vec4(u_color.rgb, u_color.a);\n vec4 mapped = clamp(u_colorMatrix * src + u_colorBias, 0.0, 1.0);\n float a = mapped.a * sdfAlpha * u_alpha;\n outColor = vec4(mapped.rgb * a, a);\n}\n`;\n\n/** Uniform names the text program declares, for the caller that looks up and\n * caches their locations. */\nexport const TEXT_SDF_UNIFORMS = [\n 'u_proj', 'u_model', 'u_atlas', 'u_color', 'u_alpha',\n 'u_synthBold', 'u_synthItalic', 'u_colorMatrix', 'u_colorBias',\n] as const;\n\n/** Vertex attribute names the text program declares, in the order the\n * interleaved buffer packs them. */\nexport const TEXT_SDF_ATTRIBUTES = ['a_position', 'a_uv', 'a_baselineY'] as const;\n"]}
1
+ {"version":3,"sources":["../src/outline/localFonts.ts","../src/textSdf.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Local Font Access → outline registrations.\n *\n * The dynamic canvas-SDF tier can render any installed family without ever\n * seeing its bytes: it asks canvas 2D to `fillText` and takes the pixels. The\n * outline tier cannot — it needs the actual font file — and the only web API\n * that hands one over is `window.queryLocalFonts()`, which is Chromium-only\n * and gated behind a permission prompt that requires a user gesture.\n *\n * So this is the one part of the tier a consumer has to *ask* for, from a\n * click. Everything downstream treats a refusal as ordinary: no\n * registrations, `glyphOutline` keeps answering `null`, and large text keeps\n * rendering from the SDF tier exactly as it does today. That ladder is\n * load-bearing rather than defensive — Safari and Firefox have no equivalent\n * API at all, so \"denied\" is the permanent state for most of the web.\n *\n * Registration is eager but reading is lazy: every matching face gets a thunk\n * that calls `FontData.blob()` at first glyph request. A machine can easily\n * have hundreds of faces and a `.ttc` runs to tens of megabytes, so\n * downloading them all at enable time would be worse than not offering the\n * tier.\n */\n\nimport type { OutlineFontStyle } from './OutlineFace';\nimport { createOpenTypeParser } from './opentypeParser';\nimport { registerFontOutlines } from './outlineRegistry';\n\n/**\n * The slice of the Local Font Access API this module uses. Declared here\n * rather than pulled from a lib: `queryLocalFonts` is not in TypeScript's DOM\n * types, and the alternative — casting `window` to `any` at the call site —\n * would lose the shape of what comes back.\n */\ninterface LocalFontData {\n postscriptName: string;\n fullName: string;\n family: string;\n style: string;\n blob(): Promise<Blob>;\n}\n\ntype LocalFontWindow = typeof globalThis & {\n queryLocalFonts?: (opts?: { postscriptNames?: string[] }) => Promise<LocalFontData[]>;\n};\n\n/** Is the Local Font Access API present at all? False on every non-Chromium\n * browser and in any non-secure context. */\nexport function canQueryLocalFonts(): boolean {\n return typeof globalThis !== 'undefined'\n && typeof (globalThis as LocalFontWindow).queryLocalFonts === 'function';\n}\n\n/**\n * CSS weights for the weight words that appear in a `style` string.\n *\n * Ordered longest-first where one name contains another (\"ExtraBold\" before\n * \"Bold\", \"DemiBold\" before \"Bold\"), because the match is a substring scan\n * and \"Bold\" would otherwise claim every heavier face for 700.\n */\nconst WEIGHT_WORDS: readonly (readonly [string, number])[] = [\n ['extrablack', 950], ['ultrablack', 950],\n ['extrabold', 800], ['ultrabold', 800],\n ['extralight', 200], ['ultralight', 200],\n ['semibold', 600], ['demibold', 600],\n ['semilight', 350],\n ['black', 900], ['heavy', 900],\n ['bold', 700],\n ['medium', 500],\n ['light', 300],\n ['thin', 100], ['hairline', 100],\n ['book', 400], ['regular', 400], ['normal', 400],\n];\n\n/**\n * Parse a `FontData.style` string — \"Regular\", \"Bold Italic\", \"Condensed\n * ExtraLight Oblique\" — into the CSS variant the kit's registry is keyed by.\n *\n * Anything unrecognized lands on 400/normal, which is what the string\n * \"Regular\" would have produced anyway. A wrong guess here costs a face that\n * registers under a variant nothing asks for; it cannot mis-paint anything,\n * because resolution is an exact-match lookup that simply misses.\n */\nexport function parseFontStyle(style: string): { weight: number; style: OutlineFontStyle } {\n const s = style.toLowerCase();\n const italic = s.includes('italic') || s.includes('oblique');\n const weight = WEIGHT_WORDS.find(([word]) => s.includes(word))?.[1] ?? 400;\n return { weight, style: italic ? 'italic' : 'normal' };\n}\n\n/**\n * How many words of a `FontData.style` string are *not* weight or slant —\n * \"Condensed\", \"Display\", \"Poster\". A face with none of them is the plain\n * member of its slot, and the one to keep when two faces collide.\n */\nfunction styleQualifierCount(style: string): number {\n const words = style.toLowerCase().split(/[\\s_-]+/).filter(Boolean);\n return words.filter((w) => w !== 'italic' && w !== 'oblique'\n && !WEIGHT_WORDS.some(([word]) => word === w)).length;\n}\n\n/** Options for `registerLocalFontOutlines`. */\nexport interface LocalFontOutlinesOptions {\n /**\n * Restrict registration to these families. Defaults to every installed\n * family. Worth passing when the consumer already knows its font menu:\n * a machine with 900 faces otherwise gets 900 registry entries, all inert\n * but all held.\n */\n families?: readonly string[];\n}\n\n/** What `registerLocalFontOutlines` registered. */\nexport interface LocalFontOutlinesResult {\n /** Families that now have at least one outline face, in menu order. */\n families: readonly string[];\n /** Total faces registered across those families. */\n faces: number;\n}\n\n/**\n * Ask for permission to read installed fonts, and register outline sources\n * for the faces it returns. **Must be called from a user gesture** — the\n * permission prompt requires one, and without it the call rejects.\n *\n * Rejects when the API is missing or the user denies; both are ordinary\n * outcomes and neither leaves the registry in a partial state, so a caller\n * that simply ignores the rejection still has a working SDF tier.\n */\nexport async function enableLocalFontOutlines(\n opts: LocalFontOutlinesOptions = {},\n): Promise<LocalFontOutlinesResult> {\n const query = (globalThis as LocalFontWindow).queryLocalFonts;\n if (typeof query !== 'function') {\n throw new Error(\n 'weasel enableLocalFontOutlines: this browser has no Local Font Access API ' +\n '(queryLocalFonts). Large text keeps rendering from the SDF tier.',\n );\n }\n\n const wanted = opts.families ? new Set(opts.families) : null;\n const data = await query.call(globalThis);\n\n // Several installed faces can land on one (family, weight, style) slot —\n // \"Helvetica Neue Condensed Bold\" reduces to 700/normal exactly like\n // \"Helvetica Neue Bold\". Registering both left whichever `queryLocalFonts`\n // happened to return last, so a condensed face could paint at the upright\n // face's advances. Pick the least-qualified name for each slot instead.\n const chosen = new Map<string, { font: LocalFontData; qualifiers: number }>();\n for (const font of data) {\n if (wanted && !wanted.has(font.family)) continue;\n const { weight, style } = parseFontStyle(font.style);\n const key = `${font.family}|${weight}|${style}`;\n const qualifiers = styleQualifierCount(font.style);\n const prev = chosen.get(key);\n if (prev && prev.qualifiers <= qualifiers) continue;\n chosen.set(key, { font, qualifiers });\n }\n\n const families = new Set<string>();\n for (const { font } of chosen.values()) {\n registerFontOutlines(font.family, parseFontStyle(font.style), () => font.blob(), {\n // The PostScript name is how a member is picked out of a `.ttc`\n // collection, which is what most macOS system families ship as.\n parser: createOpenTypeParser(font.postscriptName),\n });\n families.add(font.family);\n }\n return {\n families: [...families].sort((a, b) => a.localeCompare(b)),\n faces: chosen.size,\n };\n}\n","/**\n * GLSL ES 3.0 sources for the built-in MSDF text shader.\n *\n * Vertex inputs (interleaved, stride 20 bytes = 5 × float):\n * a_position vec2 screen-space x,y of the glyph quad vertex\n * a_uv vec2 atlas UV (0..1)\n * a_baselineY float line baseline Y in screen space (for synth-italic skew)\n *\n * Uniforms:\n * u_proj mat3 screen → clip projection\n * u_model mat3 cumulative group transform\n * u_atlas sampler2D the MSDF atlas texture (bound to TEXTURE0)\n * u_color vec4 text color (straight RGBA)\n * u_alpha float group alpha multiplier\n * u_colorMatrix mat4 color transform applied to u_color before alpha modulation\n * u_colorBias vec4 bias added after the matrix (identity = zero bias)\n *\n * Output: PREMULTIPLIED alpha — `vec4(color.rgb * a, a)` per conventions §2.\n * Blend func: ONE / ONE_MINUS_SRC_ALPHA.\n *\n * MSDF channel layout: msdf-bmfont-xml outputs R,G,B channels as independent\n * signed-distance fields covering different edge directions. The true SDF\n * value is the median of R,G,B; this recovers sharp outlines while averaging\n * out single-channel aliasing artifacts.\n *\n * Antialiasing (`aaWidth`, both shaders): the smoothstep band must be one\n * *screen* pixel wide, so it is derived per-fragment from `fwidth(sdfVal)` —\n * the rate the field changes between adjacent fragments. That single quantity\n * already folds in font size, zoom, and DPR: minify the glyph and the field\n * changes faster, so the band widens in field units to stay one pixel on\n * screen; magnify it and the band narrows.\n *\n * A *constant* band cannot be correct at more than one scale, and this shader\n * used one (0.05) until 2026-07-29. At 16px text the band collapsed to well\n * under a pixel and glyph edges quantized to hard stair-steps; at display\n * sizes the same constant read mushy. `fwidth` is core in GLSL ES 3.00, so\n * no extension guard is needed. The `max()` floor keeps a degenerate\n * derivative (flat field, or a driver returning 0) from producing a\n * zero-width band, which would be the aliased behavior all over again.\n */\n\nexport const TEXT_VERT_SRC = /* glsl */ `#version 300 es\nin vec2 a_position;\nin vec2 a_uv;\nin float a_baselineY;\nuniform mat3 u_proj;\nuniform mat3 u_model;\nuniform float u_synthItalic;\nout vec2 v_uv;\nvoid main() {\n // Synthetic italic: shift x by (a_baselineY - a_position.y) * tan(angle).\n // Above-baseline vertices (lower y in screen coords) lean further right.\n vec2 skewed = vec2(\n a_position.x + (a_baselineY - a_position.y) * tan(u_synthItalic),\n a_position.y\n );\n vec3 screen = u_model * vec3(skewed, 1.0);\n vec3 clip = u_proj * vec3(screen.xy, 1.0);\n gl_Position = vec4(clip.xy, 0.0, 1.0);\n v_uv = a_uv;\n}\n`;\n\n/** Fragment shader for the MSDF text program. Samples the atlas, applies the\n * color transform, and emits premultiplied alpha. */\nexport const TEXT_FRAG_SRC = /* glsl */ `#version 300 es\nprecision highp float;\nin vec2 v_uv;\nuniform sampler2D u_atlas;\nuniform vec4 u_color;\nuniform float u_alpha;\nuniform float u_synthBold;\nuniform mat4 u_colorMatrix;\nuniform vec4 u_colorBias;\nout vec4 outColor;\n\nfloat median(float r, float g, float b) {\n return max(min(r, g), min(max(r, g), b));\n}\n\nvoid main() {\n vec3 sdf = texture(u_atlas, v_uv).rgb;\n float sdfVal = median(sdf.r, sdf.g, sdf.b);\n // Screen-space AA band — see the file header. Half of fwidth spans ~1px.\n float aaW = max(0.5 * fwidth(sdfVal), 0.0005);\n // u_synthBold shifts the SDF threshold to thicken strokes when the\n // resolver fell back from a missing bold variant to the regular atlas.\n float threshold = 0.5 - u_synthBold;\n float msdfAlpha = smoothstep(threshold - aaW, threshold + aaW, sdfVal);\n vec4 src = vec4(u_color.rgb, u_color.a);\n vec4 mapped = clamp(u_colorMatrix * src + u_colorBias, 0.0, 1.0);\n float a = mapped.a * msdfAlpha * u_alpha;\n outColor = vec4(mapped.rgb * a, a);\n}\n`;\n\n/**\n * Single-channel sibling of TEXT_FRAG_SRC for runtime canvas-SDF glyphs\n * (DynamicGlyphAtlas R8 pages): the R channel IS the distance field, so no\n * median. Threshold semantics (0.5 edge, u_synthBold shift) match the MSDF\n * shader because the bake encodes the edge at ~128.\n *\n * Accepted trade: corner rounding away from the bake size, mildest near it.\n * `glyphRasterizer.ts` carries the measurements and the reason neither a\n * larger bake nor extra taps would improve the small-text end.\n */\nexport const TEXT_FRAG_R8_SRC = /* glsl */ `#version 300 es\nprecision highp float;\nin vec2 v_uv;\nuniform sampler2D u_atlas;\nuniform vec4 u_color;\nuniform float u_alpha;\nuniform float u_synthBold;\nuniform mat4 u_colorMatrix;\nuniform vec4 u_colorBias;\nout vec4 outColor;\n\nvoid main() {\n float sdfVal = texture(u_atlas, v_uv).r;\n // Screen-space AA band — see the file header. Half of fwidth spans ~1px.\n float aaW = max(0.5 * fwidth(sdfVal), 0.0005);\n float threshold = 0.5 - u_synthBold;\n float sdfAlpha = smoothstep(threshold - aaW, threshold + aaW, sdfVal);\n vec4 src = vec4(u_color.rgb, u_color.a);\n vec4 mapped = clamp(u_colorMatrix * src + u_colorBias, 0.0, 1.0);\n float a = mapped.a * sdfAlpha * u_alpha;\n outColor = vec4(mapped.rgb * a, a);\n}\n`;\n\n/** Uniform names the text program declares, for the caller that looks up and\n * caches their locations. */\nexport const TEXT_SDF_UNIFORMS = [\n 'u_proj', 'u_model', 'u_atlas', 'u_color', 'u_alpha',\n 'u_synthBold', 'u_synthItalic', 'u_colorMatrix', 'u_colorBias',\n] as const;\n\n/** Vertex attribute names the text program declares, in the order the\n * interleaved buffer packs them. */\nexport const TEXT_SDF_ATTRIBUTES = ['a_position', 'a_uv', 'a_baselineY'] as const;\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/font",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "MSDF font atlases, glyph metrics, and runtime glyph rasterization for @weasel-js/core. Registry, kerning-aware glyph layout, and the SDF text shader source.",
5
5
  "license": "MIT",
6
6
  "type": "module",