From cfac37ddf0c347b7e58f0f0207f9b60e51399411 Mon Sep 17 00:00:00 2001 From: be5invis Date: Fri, 2 Feb 2024 19:11:16 -0800 Subject: [PATCH] Add hollow letters and digits (#2189) --- package-lock.json | 10 +- packages/font-glyphs/package.json | 32 ++--- .../src/auto-build/transformed.ptl | 19 ++- packages/geometry-cache/src/index.mjs | 2 +- packages/geometry/package.json | 36 +++--- packages/geometry/src/curve-util.mjs | 89 +++++++++++--- packages/geometry/src/index.mjs | 78 ++++++++++++ packages/geometry/src/stroke.mjs | 114 ++++++++++++++++++ .../src/coverage-export/block-data.mjs | 3 +- 9 files changed, 318 insertions(+), 65 deletions(-) create mode 100644 packages/geometry/src/stroke.mjs diff --git a/package-lock.json b/package-lock.json index 5a4df56eb..c3a35182e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3665,9 +3665,9 @@ } }, "node_modules/typo-geom": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/typo-geom/-/typo-geom-0.13.2.tgz", - "integrity": "sha512-0xEeNX/bQl/qx1+jgMy7ObtyUK9SmdhZALCNTs2dHyTTCCpHRNHL1nPw+Us0ZmxbLRi9gy5GpINJ3tynE8K6Pw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/typo-geom/-/typo-geom-0.14.0.tgz", + "integrity": "sha512-h3KmTEdKHrD+VWrR/Oqfr/NAPyTNaEKqhcIMhpbotNiHyXTrv113NCY//o0sUswwDEpHcTxcagxdG3dm/J9hlA==", "dependencies": { "clipper-lib": "^6.4.2", "tslib": "^2.6.2" @@ -3994,7 +3994,7 @@ "@iosevka/geometry-cache": "28.0.7", "@iosevka/glyph": "28.0.7", "@iosevka/util": "28.0.7", - "typo-geom": "^0.13.2" + "typo-geom": "^0.14.0" } }, "packages/font-kits": { @@ -4021,7 +4021,7 @@ "dependencies": { "@iosevka/util": "28.0.7", "spiro": "^3.0.0", - "typo-geom": "^0.13.2" + "typo-geom": "^0.14.0" } }, "packages/geometry-cache": { diff --git a/packages/font-glyphs/package.json b/packages/font-glyphs/package.json index a398a605f..f5dff8bd9 100644 --- a/packages/font-glyphs/package.json +++ b/packages/font-glyphs/package.json @@ -1,18 +1,18 @@ { - "name": "@iosevka/font-glyphs", - "version": "28.0.7", - "private": true, - "exports": { - ".": "./src/index.mjs", - "./aesthetics": "./src/meta/aesthetics.mjs", - "./unicode-knowledge": "./src/meta/unicode-knowledge.mjs" - }, - "dependencies": { - "@iosevka/font-kits": "28.0.7", - "@iosevka/geometry": "28.0.7", - "@iosevka/geometry-cache": "28.0.7", - "@iosevka/glyph": "28.0.7", - "@iosevka/util": "28.0.7", - "typo-geom": "^0.13.2" - } + "name": "@iosevka/font-glyphs", + "version": "28.0.7", + "private": true, + "exports": { + ".": "./src/index.mjs", + "./aesthetics": "./src/meta/aesthetics.mjs", + "./unicode-knowledge": "./src/meta/unicode-knowledge.mjs" + }, + "dependencies": { + "@iosevka/font-kits": "28.0.7", + "@iosevka/geometry": "28.0.7", + "@iosevka/geometry-cache": "28.0.7", + "@iosevka/glyph": "28.0.7", + "@iosevka/util": "28.0.7", + "typo-geom": "^0.14.0" + } } diff --git a/packages/font-glyphs/src/auto-build/transformed.ptl b/packages/font-glyphs/src/auto-build/transformed.ptl index c0a6a6614..05a7d5aed 100644 --- a/packages/font-glyphs/src/auto-build/transformed.ptl +++ b/packages/font-glyphs/src/auto-build/transformed.ptl @@ -4,6 +4,7 @@ $$include '../meta/macros.ptl' import [linreg clamp mix fallback] from "@iosevka/util" import [getGrTree IsSuperscript IsSubscript AnyCv DotlessOrNot] from "@iosevka/glyph/relation" import [AnyLocalizedForm CvDecompose MathSansSerif Texture] from "@iosevka/glyph/relation" +import [BooleanGeometry StrokeGeometry] from "@iosevka/geometry" import [NumeratorForm DenominatorForm] from "@iosevka/glyph/relation" import [Transform] from "@iosevka/geometry/transform" extern Map @@ -775,14 +776,13 @@ glyph-block Autobuild-Transformed-Texture : begin createTextureDerivatives Texture.ShrR 0 SHRINK [jobs 0xF400] createTextureDerivatives Texture.ShrLR SHRINK SHRINK [jobs 0xF500] - glyph-block Autobuild-Transformed-Mathematical : begin glyph-block-import CommonShapes glyph-block-import Common-Derivatives glyph-block-import Recursive-Build : Fork glyph-block-import Autobuild-Transformed-Shared : extendRelatedGlyphs link-relations wrapName - define [createMathDerivedSeriesImpl groupName tfm _records] : begin + define [createMathDerivedSeriesImpl groupName tfm _records postProcessing] : begin local { records relSets targetNameMap } : extendRelatedGlyphs groupName _records local pendingGlyphs : records.map : [record] => record.1 local forkedPara : para.createFork tfm @@ -793,6 +793,7 @@ glyph-block Autobuild-Transformed-Mathematical : begin if [not glyphT] : console.log glyphid include glyphT AS_BASE ALSO_METRICS set currentGlyph.gizmo glyphT.gizmo + if postProcessing : include : postProcessing para forkedPara link-relations relSets @@ -822,7 +823,7 @@ glyph-block Autobuild-Transformed-Mathematical : begin define Greek2 : Array.from 'Ϝϝ' define ObliqueBlackboardBolds : Array.from '𝔻𝕕𝕖𝕚𝕛' - define [CreateMathDerivatives groupName tfm gr base letters overrides] : begin + define [CreateMathDerivatives groupName tfm gr base letters overrides postProcessing] : begin local jobs {} local overrideMap : new Map (overrides || {}) foreach j [range 0 letters.length] : begin @@ -832,7 +833,7 @@ glyph-block Autobuild-Transformed-Mathematical : begin local dst : base + j if [overrideMap.has letter] : set dst [overrideMap.get letter] if source : jobs.push { dst source } - createMathDerivedSeriesImpl groupName tfm jobs + createMathDerivedSeriesImpl groupName tfm jobs postProcessing define [CreateMathAliasableImpl groupName altGroupName tfm gr base letters overrides] : begin local overrideMap : new Map (overrides || {}) @@ -902,6 +903,16 @@ glyph-block Autobuild-Transformed-Mathematical : begin # Italic blackboard bold CreateMathDerivatives 'mathit' tfItalic null 0x2145 ObliqueBlackboardBolds + # Outlined letters and digits -- for Symbols for Legacy Computing Supplement + define [TfOutline para forkedPara] : glyph-proc + local g currentGlyph.geometry + local sw : forkedPara.stroke / 4 + local gizmo : currentGlyph.gizmo || GlobalTransform + set currentGlyph.geometry : new StrokeGeometry g gizmo sw HVContrast true + + CreateMathDerivatives 'legacyComputingOutlined' tfBold null 0x1CCD6 UpperLatin null TfOutline + CreateMathDerivatives 'legacyComputingOutlined' tfBold null 0x1CCF0 Digits null TfOutline + glyph-block Autobuild-Rhotic : begin glyph-block-import Mark-Shared-Metrics : markFine markstroke glyph-block-import CommonShapes diff --git a/packages/geometry-cache/src/index.mjs b/packages/geometry-cache/src/index.mjs index 5df043f25..b0885a0d4 100644 --- a/packages/geometry-cache/src/index.mjs +++ b/packages/geometry-cache/src/index.mjs @@ -4,7 +4,7 @@ import zlib from "zlib"; import * as CurveUtil from "@iosevka/geometry/curve-util"; import { encode, decode } from "@msgpack/msgpack"; -const Edition = 32; +const Edition = 33; const MAX_AGE = 16; class GfEntry { constructor(age, value) { diff --git a/packages/geometry/package.json b/packages/geometry/package.json index 9ffddbf2e..dbc6bd5bb 100644 --- a/packages/geometry/package.json +++ b/packages/geometry/package.json @@ -1,20 +1,20 @@ { - "name": "@iosevka/geometry", - "version": "28.0.7", - "private": true, - "exports": { - ".": "./src/index.mjs", - "./anchor": "./src/anchor.mjs", - "./box": "./src/box.mjs", - "./segment": "./src/segment.mjs", - "./curve-util": "./src/curve-util.mjs", - "./point": "./src/point.mjs", - "./transform": "./src/transform.mjs", - "./spiro-control": "./src/spiro-control.mjs" - }, - "dependencies": { - "@iosevka/util": "28.0.7", - "spiro": "^3.0.0", - "typo-geom": "^0.13.2" - } + "name": "@iosevka/geometry", + "version": "28.0.7", + "private": true, + "exports": { + ".": "./src/index.mjs", + "./anchor": "./src/anchor.mjs", + "./box": "./src/box.mjs", + "./segment": "./src/segment.mjs", + "./curve-util": "./src/curve-util.mjs", + "./point": "./src/point.mjs", + "./transform": "./src/transform.mjs", + "./spiro-control": "./src/spiro-control.mjs" + }, + "dependencies": { + "@iosevka/util": "28.0.7", + "spiro": "^3.0.0", + "typo-geom": "^0.14.0" + } } diff --git a/packages/geometry/src/curve-util.mjs b/packages/geometry/src/curve-util.mjs index 8035d1cd2..d2d77f8e2 100644 --- a/packages/geometry/src/curve-util.mjs +++ b/packages/geometry/src/curve-util.mjs @@ -1,7 +1,8 @@ import * as TypoGeom from "typo-geom"; -import { Point } from "./point.mjs"; +import { Point, Vec2 } from "./point.mjs"; import { Transform } from "./transform.mjs"; +import { mix } from "@iosevka/util"; function contourToRep(contour) { let c = []; @@ -71,6 +72,26 @@ export const OCCURRENT_PRECISION = 1 / 16; export const GEOMETRY_PRECISION = 1 / 4; export const BOOLE_RESOLUTION = 0x4000; +export function derivativeFromFiniteDifference(c, t) { + const DELTA = 1 / 0x10000; + const forward2 = c.eval(t + 2 * DELTA); + const forward1 = c.eval(t + DELTA); + const backward1 = c.eval(t - DELTA); + const backward2 = c.eval(t - 2 * DELTA); + return new Vec2( + ((1 / 12) * backward2.x - + (2 / 3) * backward1.x + + (2 / 3) * forward1.x - + (1 / 12) * forward2.x) / + DELTA, + ((1 / 12) * backward2.y - + (2 / 3) * backward1.y + + (2 / 3) * forward1.y - + (1 / 12) * forward2.y) / + DELTA + ); +} + export class OffsetCurve { constructor(bone, offset, contrast) { this.bone = bone; @@ -87,25 +108,7 @@ export class OffsetCurve { }; } derivative(t) { - const DELTA = 1 / 0x10000; - const forward = this.eval(t + DELTA); - const backward = this.eval(t - DELTA); - return { - x: (forward.x - backward.x) / (2 * DELTA), - y: (forward.y - backward.y) / (2 * DELTA) - }; - } -} - -export class ReverseCurve { - constructor(original) { - this.m_original = original; - } - eval(t) { - return this.m_original.eval(1 - t); - } - derivative(t) { - return -this.m_original.derivative(1 - t); + return derivativeFromFiniteDifference(this, t); } } @@ -149,3 +152,49 @@ export class BezToContoursSink { this.lastContour.push(Point.transformedXY(this.gizmo, Point.Type.Corner, x, y)); } } + +export function Bez3FromHermite(zStart, dStart, zEnd, dEnd) { + const a = zStart, + d = zEnd; + const b = new Vec2(a.x + dStart.x / 3, a.y + dStart.y / 3); + const c = new Vec2(d.x - dEnd.x / 3, d.y - dEnd.y / 3); + return new TypoGeom.Arcs.Bez3(a, b, c, d); +} + +export class RoundCapCurve { + constructor(side, contrast, center0, point0, center1, point1) { + this.contrast = contrast; + this.center0 = center0; + this.center1 = center1; + + const theta0 = Math.atan2(point0.y - center0.y, (point0.x - center0.x) / contrast); + let theta1 = Math.atan2(point1.y - center1.y, (point1.x - center1.x) / contrast); + if (side) { + while (theta1 < theta0) theta1 += 2 * Math.PI; + } else { + while (theta1 > theta0) theta1 -= 2 * Math.PI; + } + this.theta0 = theta0; + this.theta1 = theta1; + + this.r0 = Math.hypot(center0.y - point0.y, (center0.x - point0.x) / contrast); + this.r1 = Math.hypot(center1.y - point1.y, (center1.x - point1.x) / contrast); + } + + eval(t) { + const centerX = mix(this.center0.x, this.center1.x, t); + const centerY = mix(this.center0.y, this.center1.y, t); + const r = mix(this.r0, this.r1, t); + const theta = mix(this.theta0, this.theta1, t); + + return { + x: centerX + r * Math.cos(theta) * this.contrast, + y: centerY + r * Math.sin(theta) + }; + } + + derivative(t) { + // TODO: calculate an exact form instead of using finite difference + return derivativeFromFiniteDifference(this, t); + } +} diff --git a/packages/geometry/src/index.mjs b/packages/geometry/src/index.mjs index 13d11a56a..405da8850 100644 --- a/packages/geometry/src/index.mjs +++ b/packages/geometry/src/index.mjs @@ -9,6 +9,7 @@ import { Point } from "./point.mjs"; import { QuadifySink } from "./quadify.mjs"; import { SpiroExpander } from "./spiro-expand.mjs"; import { Transform } from "./transform.mjs"; +import { strokeArcs } from "./stroke.mjs"; export const CPLX_NON_EMPTY = 0x01; // A geometry tree that is not empty export const CPLX_NON_SIMPLE = 0x02; // A geometry tree that contains non-simple contours @@ -533,6 +534,83 @@ export class BooleanGeometry extends GeometryBase { } } +export class StrokeGeometry extends GeometryBase { + constructor(geom, gizmo, radius, contrast, fInside) { + super(); + this.m_geom = geom; + this.m_gizmo = gizmo; + this.m_radius = radius; + this.m_contrast = contrast; + this.m_fInside = fInside; + } + + asContours() { + // Produce simplified arcs + const nonTransformedGeometry = new TransformedGeometry(this.m_geom, this.m_gizmo.inverse()); + let arcs = TypoGeom.Boolean.removeOverlap( + CurveUtil.convertShapeToArcs(nonTransformedGeometry.asContours()), + TypoGeom.Boolean.PolyFillType.pftNonZero, + CurveUtil.BOOLE_RESOLUTION + ); + + // Fairize to get get some arcs that are simple enough + const fairizedArcs = TypoGeom.Fairize.fairizeBezierShape(arcs); + + // Stroke the arcs + const strokedArcs = strokeArcs( + fairizedArcs, + this.m_radius, + this.m_contrast, + this.m_fInside + ); + + // Convert to Iosevka format + let sink = new CurveUtil.BezToContoursSink(this.m_gizmo); + TypoGeom.ShapeConv.transferBezArcShape(strokedArcs, sink, CurveUtil.GEOMETRY_PRECISION); + + return sink.contours; + } + asReferences() { + return null; + } + getDependencies() { + return this.m_geom.getDependencies(); + } + unlinkReferences() { + return new StrokeGeometry( + this.m_geom.unlinkReferences(), + this.m_gizmo, + this.m_radius, + this.m_contrast, + this.m_fInside + ); + } + filterTag(fn) { + return new StrokeGeometry( + this.m_geom.filterTag(fn), + this.m_gizmo, + this.m_radius, + this.m_contrast, + this.m_fInside + ); + } + measureComplexity() { + return this.m_geom.measureComplexity() | CPLX_NON_SIMPLE; + } + toShapeStringOrNull() { + const sTarget = this.m_geom.unlinkReferences().toShapeStringOrNull(); + if (!sTarget) return null; + return Format.struct( + `StrokeGeometry`, + sTarget, + Format.gizmo(this.m_gizmo), + Format.n(this.m_radius), + Format.n(this.m_contrast), + this.m_fInside + ); + } +} + // This special geometry type is used in the finalization phase to create TTF contours. export class SimplifyGeometry extends GeometryBase { constructor(g) { diff --git a/packages/geometry/src/stroke.mjs b/packages/geometry/src/stroke.mjs new file mode 100644 index 000000000..ced2de37a --- /dev/null +++ b/packages/geometry/src/stroke.mjs @@ -0,0 +1,114 @@ +import * as TypoGeom from "typo-geom"; +import { + BOOLE_RESOLUTION, + Bez3FromHermite, + GEOMETRY_PRECISION, + OCCURRENT_PRECISION, + OffsetCurve, + RoundCapCurve +} from "./curve-util.mjs"; + +export function strokeArcs(arcs, radius, contrast, fInside) { + let currentArcs = null; + for (const contour of arcs) { + let leftSide = offsetContour(contour, -radius, contrast); + let rightSide = offsetContour(contour, radius, contrast); + let bezs = TypoGeom.ShapeConv.convertShapeToBez3([leftSide, rightSide], GEOMETRY_PRECISION); + + if (!currentArcs) { + currentArcs = bezs; + } else { + currentArcs = TypoGeom.Boolean.combine( + TypoGeom.Boolean.ClipType.ctUnion, + currentArcs, + bezs, + TypoGeom.Boolean.PolyFillType.pftNonZero, + TypoGeom.Boolean.PolyFillType.pftNonZero, + BOOLE_RESOLUTION + ); + } + } + + if (currentArcs) { + if (fInside) { + return TypoGeom.Boolean.combine( + TypoGeom.Boolean.ClipType.ctIntersection, + TypoGeom.ShapeConv.convertShapeToBez3(arcs, GEOMETRY_PRECISION), + currentArcs, + TypoGeom.Boolean.PolyFillType.pftNonZero, + TypoGeom.Boolean.PolyFillType.pftNonZero, + BOOLE_RESOLUTION + ); + } else { + return currentArcs; + } + } else { + return []; + } +} + +function offsetContour(arcs, distance, contrast) { + // The arcs here are guaranteed to be simple, i.e. no self-intersections. + const fReverse = distance < 0; + let offsetArcs = []; + let prevOffsetedArc = new OffsetCurve(arcs[arcs.length - 1], distance, contrast); + for (let i = 0; i < arcs.length; i++) { + const current = arcs[i]; + const currentOffsetedArc = new OffsetCurve(current, distance, contrast); + + // Evaluate the previous' end and the current's start, determine whether they are close enough + const prevEnd = prevOffsetedArc.eval(1); + const currentStart = currentOffsetedArc.eval(0); + if ( + Math.abs(prevEnd.x - currentStart.x) > OCCURRENT_PRECISION || + Math.abs(prevEnd.y - currentStart.y) > OCCURRENT_PRECISION + ) { + offsetArcs.push( + createCap( + distance < 0, + contrast, + prevOffsetedArc.bone.eval(1), + prevEnd, + prevOffsetedArc.derivative(1), + currentOffsetedArc.bone.eval(0), + currentStart, + currentOffsetedArc.derivative(0) + ) + ); + // offsetArcs.push(Bez3FromHermite(prevEnd, dPrevEnd, currentStart, dCurrentStart)); + } + + // Push the current arc + offsetArcs.push(currentOffsetedArc); + + prevOffsetedArc = currentOffsetedArc; + } + + if (fReverse) { + offsetArcs.reverse(); + for (let i = 0; i < offsetArcs.length; i++) { + offsetArcs[i] = new TypoGeom.Arcs.Reverted(offsetArcs[i]); + } + } + return offsetArcs; +} + +function createCap( + side, + contrast, + prevEndNoOffset, // Previous non-offseted curve's end point + prevEnd, // Previous offseted curve's end point + dPrevEnd, // Previous offseted curve's end point's derivative + currentStartNoOffset, // Current non-offseted curve's start point + currentStart, // Current offseted curve's start point + dCurrentStart // Current offseted curve's start point's derivative +) { + return new RoundCapCurve( + side, + contrast, + prevEndNoOffset, + prevEnd, + currentStartNoOffset, + currentStart + ); +} diff --git a/tools/data-export/src/coverage-export/block-data.mjs b/tools/data-export/src/coverage-export/block-data.mjs index 9eac34df5..dd0c3b965 100644 --- a/tools/data-export/src/coverage-export/block-data.mjs +++ b/tools/data-export/src/coverage-export/block-data.mjs @@ -4,7 +4,8 @@ export async function collectBlockData() { const BlockData = [ [[0xe0a0, 0xe0df], "Private Use Area — Powerline"], [[0xee00, 0xee0f], "Private Use Area — Progress Bar"], - [[0xef10, 0xef1f], "Private Use Area — Iosevka Private Dingbats"] + [[0xef10, 0xef1f], "Private Use Area — Iosevka Private Dingbats"], + [[0x1cc00, 0x1ceaf], "Symbols for Legacy Computing Supplement"] ]; for (const id of UnicodeDataIndex.Block) {