dsh-fonttune 0.7.3 → 0.7.4

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/CHANGELOG.en.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to **dsh-fonttune** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.7.4] - 2026-10-30
6
+
7
+ ### Fixed
8
+ - **The weight slider moved the Latin and the Chinese text by different amounts**: when a Chinese family ships only a few faces, some notches changed only the Latin side and others made the browser thicken the Chinese one itself, so one script read heavier than the other. The notches are now taken from what both families can draw, and each one changes the two sides by a similar amount.
9
+
10
+ ### Changed
11
+ - **How many notches you get follows the faces the fonts ship**: a Chinese family with more faces offers more notches — three faces give at most three positions, seven give about twice that. The readout counts positions, so it changes with the font.
12
+
5
13
  ## [0.7.3] - 2026-10-29
6
14
 
7
15
  ### Fixed
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  **dsh-fonttune** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.7.4] - 2026-10-30
6
+
7
+ ### 修复
8
+ - **中英混排时两边字重不同步**:一套中文字体只带几个字面时,字重滑块有些档位只让西文变、中文不动,有些档位是浏览器把中文自动加粗,读起来一边太重一边太轻。现在档位取自两种字体都能真画出来的范围,每档两边变化幅度接近。
9
+
10
+ ### 变更
11
+ - **档位数量按字体能画的字面计算**:中文字面越多的字体,滑块给的档位越多——只有三个字面的中文字体最多三档,七个字面的约多一倍。读数是档数,换字体后档数会跟着变。
12
+
5
13
  ## [0.7.3] - 2026-10-29
6
14
 
7
15
  ### 修复
package/lib/client.js CHANGED
@@ -3263,6 +3263,294 @@ function ceilTo(value, step) {
3263
3263
  return Math.ceil(value / step) * step;
3264
3264
  }
3265
3265
 
3266
+ /**
3267
+ * How far apart two families may land on one notch before the stack refuses it.
3268
+ *
3269
+ * Measured on the reporter's pair (issue #4): one 100-weight notch adds +21.5%
3270
+ * ink to the Latin family and +23.9% to the Chinese one — 2.4 points apart, which
3271
+ * no reader notices. One 200-weight notch adds +44% and +51% — 7.4 points apart,
3272
+ * and that is the reported "one side suits, the other is too heavy". Five points
3273
+ * is the budget between those two observations.
3274
+ */
3275
+ var INK_BALANCE_SPREAD = 0.05;
3276
+
3277
+ /**
3278
+ * How far apart a pair may land when the strict budget would leave no control.
3279
+ *
3280
+ * Measured on the reporter's pair (issue #4): going one notch LIGHTER, the Latin
3281
+ * family loses 29.7% of its ink and the Chinese one 17.9% — 11.9 points apart, and
3282
+ * no finer notch makes it better, because the two families simply space their faces
3283
+ * differently. Under the strict budget the reader would get a weight control that
3284
+ * cannot go lighter at all (two positions). The wider budget is what keeps that
3285
+ * physical pair usable, and it is only consulted when the strict one is: a stack
3286
+ * that has four even positions never sees it.
3287
+ */
3288
+ var INK_BALANCE_SPREAD_WIDE = 0.15;
3289
+
3290
+ /** How many notches the evenness walk looks at, one way. */
3291
+ var WEIGHT_MAX_POSITIONS = 24;
3292
+
3293
+ /**
3294
+ * The least ink one notch has to add before a position is worth having.
3295
+ *
3296
+ * This is the bar `weightProfileFrom` already measured on this host: a 200-weight
3297
+ * step is +30% ink and a 100-weight step +16%, and 100 was not enough — a reader
3298
+ * could not see it. Five per cent would put a variable family's stack at twenty-odd
3299
+ * notches that each move a little (measured live: 29 positions of +6%), which is the
3300
+ * dead-control the coarse step exists to avoid. Fifteen keeps every pair the faces
3301
+ * do allow (the reported one tops out at +19%/+22%) and stops there.
3302
+ */
3303
+ var INK_BALANCE_STEP = 0.15;
3304
+
3305
+ /**
3306
+ * One family's ink at one weight.
3307
+ *
3308
+ * A family answers through `inkOf`, the sampler its own measurement already uses;
3309
+ * a weight it cannot measure — or a family that cannot be measured at all — answers
3310
+ * null and is left out of a comparison instead of counting as a zero.
3311
+ * @param {Object} entry - one measured family.
3312
+ * @param {number} weight - CSS weight.
3313
+ * @returns {number|null} the ink, or null when there is no measurement.
3314
+ */
3315
+ function entryInk(entry, weight) {
3316
+ if (entry === null || typeof entry !== "object") return null;
3317
+ if (typeof entry.inkOf !== "function") return null;
3318
+ var ink = null;
3319
+ try {
3320
+ ink = entry.inkOf(weight);
3321
+ } catch (error) {
3322
+ return null;
3323
+ }
3324
+ ink = Number(ink);
3325
+ if (!isFinite(ink) || ink <= 0) return null;
3326
+ return ink;
3327
+ }
3328
+
3329
+ /**
3330
+ * How much moving from one weight to another changes each family's ink.
3331
+ *
3332
+ * The share is relative to the family's OWN ink at `from`, so two families that
3333
+ * start out different sizes are still comparable: +20% means the same thing for
3334
+ * both. Families that cannot be measured on both sides are left out.
3335
+ * @param {Array<Object>} entries - the measured families.
3336
+ * @param {number} from - the weight the reader is at.
3337
+ * @param {number} to - the weight being compared.
3338
+ * @returns {number[]|null} the relative changes, or null when fewer than two
3339
+ * families can be measured.
3340
+ */
3341
+ function inkShares(entries, from, to) {
3342
+ var shares = [];
3343
+ for (var index = 0; index < entries.length; index += 1) {
3344
+ var before = entryInk(entries[index], from);
3345
+ var after = entryInk(entries[index], to);
3346
+ if (before === null || after === null) continue;
3347
+ shares.push((after - before) / before);
3348
+ }
3349
+ return shares.length < 2 ? null : shares;
3350
+ }
3351
+
3352
+ /**
3353
+ * How far apart the families land, and how little the quietest one moved.
3354
+ * @param {number[]} shares - one relative change per family.
3355
+ * @returns {{spread: number, smallest: number}} `spread` is the signed range, so
3356
+ * families moving in opposite directions count as far apart.
3357
+ */
3358
+ function shareSpread(shares) {
3359
+ var least = shares[0];
3360
+ var most = shares[0];
3361
+ var smallest = Math.abs(shares[0]);
3362
+ for (var index = 1; index < shares.length; index += 1) {
3363
+ if (shares[index] < least) least = shares[index];
3364
+ if (shares[index] > most) most = shares[index];
3365
+ var size = Math.abs(shares[index]);
3366
+ if (size < smallest) smallest = size;
3367
+ }
3368
+ return { spread: most - least, smallest: smallest };
3369
+ }
3370
+
3371
+ /**
3372
+ * The evenness verdict for one move of the stack.
3373
+ * @param {Array<Object>} entries - the measured families.
3374
+ * @param {number} from - the weight the move starts at.
3375
+ * @param {number} to - the weight it ends at.
3376
+ * @returns {{spread: number, smallest: number}|null} the verdict, or null when it
3377
+ * cannot be measured.
3378
+ */
3379
+ function evennessOf(entries, from, to) {
3380
+ var shares = inkShares(entries, from, to);
3381
+ if (shares === null) return null;
3382
+ return shareSpread(shares);
3383
+ }
3384
+
3385
+ /** Is one move of the stack both visible and even? */
3386
+ function moveIsEven(verdict, spread) {
3387
+ return verdict !== null && verdict.smallest >= INK_BALANCE_STEP && verdict.spread <= spread;
3388
+ }
3389
+
3390
+ /**
3391
+ * The shape a stack of families can follow together, chosen for EVENNESS.
3392
+ *
3393
+ * Issue #4 is not about the range: it is about two families being moved by one
3394
+ * offset and landing on faces that are not the same distance apart. The test is
3395
+ * therefore walked outwards from the neutral position, one notch at a time, and a
3396
+ * notch only survives when BOTH hold:
3397
+ * - every family's ink changes by at least `INK_BALANCE_STEP` (the notch is
3398
+ * visible), and
3399
+ * - the families land within `INK_BALANCE_SPREAD` of each other, measured both for
3400
+ * that one notch and for the whole way back to the neutral position — a stack
3401
+ * can be even notch by notch and still drift apart after two of them.
3402
+ *
3403
+ * The walk is what removes the synthetic-bold step without ever having to ask the
3404
+ * browser whether a weight is drawn or faked: above its last real face a family is
3405
+ * stroked thicker by the browser, which adds a much larger share of ink than a drawn
3406
+ * face does — so the notch where one family jumps and the other does not simply
3407
+ * fails the budget and the range stops in front of it.
3408
+ *
3409
+ * Among the shapes that survive, the one with the MOST positions wins: the visible
3410
+ * floor above is what makes a finer notch worth having, and more positions is more
3411
+ * control. (A single family is not put through this at all — it keeps the coarse
3412
+ * shape `weightProfileFrom` measured for it.)
3413
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}>} entries
3414
+ * one record per measured family.
3415
+ * @param {number} base - the weight the offset is added to (body text).
3416
+ * @param {number} low - the lowest offset every family can express.
3417
+ * @param {number} high - the highest offset every family can express.
3418
+ * @returns {{min: number, max: number, step: number}|null} the shape, or null when
3419
+ * no step leaves the stack two positions to move between.
3420
+ */
3421
+ function balancedStackShape(entries, base, low, high, spread) {
3422
+ var budget = typeof spread === "number" && spread > 0 ? spread : INK_BALANCE_SPREAD;
3423
+ var best = null;
3424
+ var bestCount = 1;
3425
+ var bestScore = Infinity;
3426
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3427
+ var step = WEIGHT_STEP_CANDIDATES[index];
3428
+ if (!(step > 0)) continue;
3429
+ var up = 0;
3430
+ var down = 0;
3431
+ // A walk is bounded: no useful shape has more than a couple of dozen
3432
+ // positions, and an unbounded one on a one-unit grid would cost hundreds of
3433
+ // canvas renders on the very first measurement.
3434
+ var limit = step * WEIGHT_MAX_POSITIONS;
3435
+ for (var position = step; position <= high && position <= limit; position += step) {
3436
+ if (!moveIsEven(evennessOf(entries, base + position - step, base + position), budget)) break;
3437
+ if (!moveIsEven(evennessOf(entries, base, base + position), budget)) break;
3438
+ up = position;
3439
+ }
3440
+ for (var back = step; back <= -low && back <= limit; back += step) {
3441
+ if (!moveIsEven(evennessOf(entries, base - back + step, base - back), budget)) break;
3442
+ if (!moveIsEven(evennessOf(entries, base, base - back), budget)) break;
3443
+ down = back;
3444
+ }
3445
+ var count = (up + down) / step + 1;
3446
+ if (count < 2) continue;
3447
+ // How close the two families are on one notch from the neutral position: a tie
3448
+ // between two shapes that move the stack over the same number of positions.
3449
+ var headroom = evennessOf(entries, base, base + step);
3450
+ if (headroom === null) headroom = evennessOf(entries, base, base - step);
3451
+ var score = headroom === null ? Infinity : headroom.spread;
3452
+ if (count > bestCount || (count === bestCount && score < bestScore)) {
3453
+ // -0 would read as a different number to a strict comparison.
3454
+ best = { min: down === 0 ? 0 : -down, max: up, step: step };
3455
+ bestCount = count;
3456
+ bestScore = score;
3457
+ }
3458
+ }
3459
+ return best;
3460
+ }
3461
+
3462
+ /**
3463
+ * One slider shape for a stack whose text can land on more than one family.
3464
+ *
3465
+ * A reader sets ONE offset; the browser then renders English with the first
3466
+ * family and Chinese with the fallback, each snapping that same requested value
3467
+ * to its own faces. When the fallback ships three faces and the primary is a
3468
+ * variable font, a step measured on the primary alone leaves the fallback
3469
+ * unchanged on most notches — the reported "one side suits, the other is too
3470
+ * heavy or too light" (issue #4: Inclusive Sans beside a three-face CJK family).
3471
+ *
3472
+ * The combined shape keeps every family moving on every notch, but the notch is
3473
+ * chosen for EVENNESS (see `balancedStackShape`): each family has to change by a
3474
+ * comparable share of its own ink, and the stack has to stay together over the
3475
+ * whole length of the range. Families that can report their ink (`inkOf`) get that
3476
+ * shape; a stack measured without a canvas keeps the plain shared-multiple shape,
3477
+ * which is also what a single family gets.
3478
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}|null>} profiles
3479
+ * one shape per family.
3480
+ * @param {{base?: number}} [options] `base` is the weight the offset is added to
3481
+ * (body text); it anchors the ink comparison.
3482
+ * @returns {{min: number, max: number, step: number}|null} the combined shape, or
3483
+ * null when nothing usable was measured.
3484
+ */
3485
+ function combineWeightProfiles(profiles, options) {
3486
+ var list = [];
3487
+ if (Array.isArray(profiles)) {
3488
+ for (var index = 0; index < profiles.length; index += 1) {
3489
+ var entry = profiles[index];
3490
+ if (entry === null || typeof entry !== "object") continue;
3491
+ if (!isFinite(entry.min) || !isFinite(entry.max) || !isFinite(entry.step)) continue;
3492
+ if (!(entry.step > 0)) continue;
3493
+ list.push(entry);
3494
+ }
3495
+ }
3496
+ if (list.length === 0) return null;
3497
+ if (list.length === 1) return { min: list[0].min, max: list[0].max, step: list[0].step };
3498
+ var step = list[0].step;
3499
+ for (var other = 1; other < list.length; other += 1) {
3500
+ step = commonStep(step, list[other].step);
3501
+ }
3502
+ var low = list[0].min;
3503
+ var high = list[0].max;
3504
+ for (var more = 1; more < list.length; more += 1) {
3505
+ low = Math.max(low, list[more].min);
3506
+ high = Math.min(high, list[more].max);
3507
+ }
3508
+ var settings = options === null || typeof options !== "object" ? {} : options;
3509
+ var base = isFinite(settings.base) ? Number(settings.base) : WEIGHT_BASE;
3510
+ // Every family that can report ink is put through the evenness walk; a stack whose
3511
+ // canvas could not answer keeps the plain shared-multiple shape.
3512
+ var measured = false;
3513
+ for (var scan = 0; scan < list.length; scan += 1) {
3514
+ if (typeof list[scan].inkOf === "function") measured = true;
3515
+ }
3516
+ if (measured) {
3517
+ var even = balancedStackShape(list, base, low, high);
3518
+ var positions = even === null ? 0 : (even.max - even.min) / even.step + 1;
3519
+ if (positions >= 3) return even;
3520
+ // The pair's own faces are spaced so unevenly that the strict budget leaves
3521
+ // no control; the wider one is what keeps a lighter position reachable.
3522
+ var relaxed = balancedStackShape(list, base, low, high, INK_BALANCE_SPREAD_WIDE);
3523
+ var relaxedPositions = relaxed === null ? 0 : (relaxed.max - relaxed.min) / relaxed.step + 1;
3524
+ if (relaxedPositions > positions) return relaxed;
3525
+ if (even !== null) return even;
3526
+ }
3527
+ var onGrid = { min: ceilTo(low, step), max: floorTo(high, step) };
3528
+ if (onGrid.max >= onGrid.min) return { min: onGrid.min, max: onGrid.max, step: step };
3529
+ var widest = list[0];
3530
+ for (var alt = 1; alt < list.length; alt += 1) {
3531
+ if (list[alt].max - list[alt].min > widest.max - widest.min) widest = list[alt];
3532
+ }
3533
+ return { min: widest.min, max: widest.max, step: widest.step };
3534
+ }
3535
+
3536
+ /**
3537
+ * The smallest candidate step that is a whole multiple of both inputs.
3538
+ *
3539
+ * Profiles come from one candidate list, so a shared multiple usually exists
3540
+ * there; two steps that share none fall back to the coarser one, which still
3541
+ * moves the coarser family on every notch.
3542
+ * @param {number} a - one step.
3543
+ * @param {number} b - another step.
3544
+ * @returns {number} the combined step.
3545
+ */
3546
+ function commonStep(a, b) {
3547
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3548
+ var candidate = WEIGHT_STEP_CANDIDATES[index];
3549
+ if (candidate % a === 0 && candidate % b === 0) return candidate;
3550
+ }
3551
+ return Math.max(a, b);
3552
+ }
3553
+
3266
3554
  /**
3267
3555
  * Does every position of `[min..max]` in `step` increments render differently?
3268
3556
  *
@@ -4846,6 +5134,16 @@ var shared = {
4846
5134
  WEIGHT_CSS_MIN: WEIGHT_CSS_MIN,
4847
5135
  WEIGHT_CSS_MAX: WEIGHT_CSS_MAX,
4848
5136
  weightProfileFrom: weightProfileFrom,
5137
+ combineWeightProfiles: combineWeightProfiles,
5138
+ balancedStackShape: balancedStackShape,
5139
+ evennessOf: evennessOf,
5140
+ INK_BALANCE_SPREAD: INK_BALANCE_SPREAD,
5141
+ INK_BALANCE_STEP: INK_BALANCE_STEP,
5142
+ INK_BALANCE_SPREAD_WIDE: INK_BALANCE_SPREAD_WIDE,
5143
+ WEIGHT_PROBE_LOW: WEIGHT_PROBE_LOW,
5144
+ WEIGHT_PROBE_HIGH: WEIGHT_PROBE_HIGH,
5145
+ WEIGHT_PROBE_STEP: WEIGHT_PROBE_STEP,
5146
+ GENERIC_FAMILIES: GENERIC_FAMILIES,
4849
5147
  weightStepRange: weightStepRange,
4850
5148
  isShorthandName: isShorthandName,
4851
5149
  isScaledToken: isScaledToken,
@@ -6167,6 +6465,114 @@ var weightProbeCanvas = null;
6167
6465
  /** Cache of measured slider shapes, keyed by the resolved family stack. */
6168
6466
  var weightProfileCache = {};
6169
6467
 
6468
+ /**
6469
+ * The families a stack can actually render text with.
6470
+ *
6471
+ * The first family draws the Latin text and a later one draws whatever the first
6472
+ * cannot (that is where the Chinese comes from), so the notch step has to be one
6473
+ * every one of them can follow. Generic keywords are dropped: `serif` is not a
6474
+ * family anyone ships faces for.
6475
+ * @param {string} stack - resolved `font-family` value.
6476
+ * @returns {string[]} the family names, primary first.
6477
+ */
6478
+ function stackFamilies(stack) {
6479
+ var parsed = shared.parseStack(String(stack));
6480
+ var out = [];
6481
+ for (var index = 0; index < parsed.length; index += 1) {
6482
+ var name = String(parsed[index]).trim();
6483
+ if (name === "") continue;
6484
+ if (shared.GENERIC_FAMILIES.indexOf(name.toLowerCase()) >= 0) continue;
6485
+ if (out.indexOf(name) >= 0) continue;
6486
+ out.push(name);
6487
+ }
6488
+ return out;
6489
+ }
6490
+
6491
+ /**
6492
+ * How many families of one stack are measured.
6493
+ *
6494
+ * Two covers the reported case — a Latin family with the CJK fallback behind it
6495
+ * — and keeps the first measurement to about twice what it was: the probe renders
6496
+ * the family on a canvas for every weight it asks about.
6497
+ */
6498
+ var WEIGHT_STACK_FAMILIES = 2;
6499
+
6500
+ /** Measured shapes per family, so a combined stack measures each family once. */
6501
+ var familyProfileCache = {};
6502
+
6503
+ /**
6504
+ * The text each family of a stack is measured on when the two have to move together.
6505
+ *
6506
+ * A stack is one Latin family followed by whatever draws the Chinese, and each of
6507
+ * them draws only part of a mixed sample: measured on mixed text the Latin family
6508
+ * borrows the Chinese from the browser's own fallback — a third family the reader
6509
+ * never sees beside it — and its ink curve would then partly describe that one. The
6510
+ * evenness comparison therefore measures each family on the script it renders.
6511
+ */
6512
+ var WEIGHT_SAMPLE_LATIN = "Hamburgefonstiv 0189";
6513
+ var WEIGHT_SAMPLE_FALLBACK = "对话字体粗细大小变化";
6514
+
6515
+ /**
6516
+ * One family's slider shape, measured through its alias when it is promoted.
6517
+ *
6518
+ * `null` means the canvas could not answer; the caller then falls back to the
6519
+ * nominal window instead of offering a control built on no data.
6520
+ *
6521
+ * When the stack has a second family, the record also carries `inkOf`, a sampler
6522
+ * for that family's ink on the script it draws. `combineWeightProfiles` walks it to
6523
+ * find the notch and range that move both families evenly (issue #4).
6524
+ * @param {string} family - the family name, unquoted.
6525
+ * @param {number} base - the heaviest weight the offset lands on.
6526
+ * @param {string} alias - the variable alias to measure through, "" when none.
6527
+ * @param {string|null} [inkText] - the text the ink is measured on, or null when the
6528
+ * stack has nothing to be even with.
6529
+ * @returns {{min: number, max: number, step: number, inkOf: Function|null}|null}
6530
+ * the shape plus the ink sampler.
6531
+ */
6532
+ function familyWeightProfile(family, base, alias, inkText) {
6533
+ var text = typeof inkText === "string" && inkText !== "" ? inkText : null;
6534
+ var key =
6535
+ family +
6536
+ "\u0000" +
6537
+ base +
6538
+ "\u0000" +
6539
+ (alias === "" ? "stepped" : "continuous") +
6540
+ "\u0000" +
6541
+ (text === null ? "plain" : "ink");
6542
+ if (Object.prototype.hasOwnProperty.call(familyProfileCache, key)) {
6543
+ return familyProfileCache[key];
6544
+ }
6545
+ var record = null;
6546
+ try {
6547
+ var target = '"' + (alias === "" ? family : alias) + '"';
6548
+ var seen = {};
6549
+ var sample = function (weight) {
6550
+ if (!Object.prototype.hasOwnProperty.call(seen, weight)) {
6551
+ seen[weight] = sampleWeightSignature(target, weight);
6552
+ }
6553
+ return seen[weight];
6554
+ };
6555
+ var shape =
6556
+ alias === ""
6557
+ ? weightProfileFrom(sample, { top: base })
6558
+ : weightProfileFrom(sample, { top: base, candidates: shared.CONTINUOUS_CANDIDATES });
6559
+ record = { min: shape.min, max: shape.max, step: shape.step, inkOf: null };
6560
+ if (text !== null) {
6561
+ var inks = {};
6562
+ record.inkOf = function (weight) {
6563
+ if (!Object.prototype.hasOwnProperty.call(inks, weight)) {
6564
+ inks[weight] = inkOfSignature(sampleWeightSignature(target, weight, text));
6565
+ }
6566
+ return inks[weight];
6567
+ };
6568
+ }
6569
+ } catch (error) {
6570
+ record = null;
6571
+ }
6572
+ familyProfileCache[key] = record;
6573
+ return record;
6574
+ }
6575
+
6170
6576
  /**
6171
6577
  * Values the card has moved but the settings document has not confirmed yet.
6172
6578
  *
@@ -6497,13 +6903,17 @@ function probeLog(kind, detail) {
6497
6903
  * — and report a coarse family where the weights are in fact continuous.
6498
6904
  * @param {string} stack - resolved `font-family` value.
6499
6905
  * @param {number} weight - CSS weight to draw with.
6906
+ * @param {string} [text] - the text to draw; the card's shared sample by default.
6500
6907
  * @returns {string} the signature; equal signatures mean identical rendering.
6501
6908
  */
6502
- function sampleWeightSignature(stack, weight) {
6909
+ function sampleWeightSignature(stack, weight, text) {
6910
+ var sampleText = typeof text === "string" && text !== "" ? text : WEIGHT_SAMPLE_TEXT;
6911
+ // Wide and tall enough for the longest sample (a run of CJK glyphs at 32px) and
6912
+ // for the line box no family clips: a clipped glyph would undercount its ink.
6503
6913
  if (weightProbeCanvas === null) {
6504
6914
  weightProbeCanvas = document.createElement("canvas");
6505
- weightProbeCanvas.width = 260;
6506
- weightProbeCanvas.height = 56;
6915
+ weightProbeCanvas.width = 420;
6916
+ weightProbeCanvas.height = 64;
6507
6917
  }
6508
6918
  // `willReadFrequently` matters here: the profile probe reads the canvas back once
6509
6919
  // per sampled weight (~80 times on the first measurement), and without it the
@@ -6514,8 +6924,8 @@ var context = weightProbeCanvas.getContext("2d", { willReadFrequently: true });
6514
6924
  context.font = weight + " 32px " + stack;
6515
6925
  context.textBaseline = "top";
6516
6926
  context.fillStyle = "#000";
6517
- context.fillText(WEIGHT_SAMPLE_TEXT, 2, 6);
6518
- var advance = Math.round(context.measureText(WEIGHT_SAMPLE_TEXT).width * 1000);
6927
+ context.fillText(sampleText, 2, 6);
6928
+ var advance = Math.round(context.measureText(sampleText).width * 1000);
6519
6929
  var pixels = context.getImageData(0, 0, weightProbeCanvas.width, weightProbeCanvas.height).data;
6520
6930
  var ink = 0;
6521
6931
  for (var index = 3; index < pixels.length; index += 4) ink += pixels[index];
@@ -6569,21 +6979,38 @@ function weightProfileFor(stack, top) {
6569
6979
  var profile = promoted
6570
6980
  ? shared.continuousWeightProfile()
6571
6981
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
6572
- try {
6573
- // A continuous family is measured THROUGH ITS ALIAS, on the fine candidate grid:
6574
- // the axis answers at every notch, so the finest one that still moves body text
6575
- // wins, and the range stops where the family's own axis does instead of running
6576
- // past it into weights that render the same.
6577
- var target = promoted ? '"' + alias + '"' : resolved;
6578
- var seen = {};
6579
- var sample = function (weight) {
6580
- if (!Object.prototype.hasOwnProperty.call(seen, weight)) {
6581
- seen[weight] = sampleWeightSignature(target, weight);
6582
- }
6583
- return seen[weight];
6584
- };
6585
- profile = weightProfileFrom(sample, promoted ? { top: base, candidates: shared.CONTINUOUS_CANDIDATES } : { top: base });
6586
- } catch (error) {
6982
+ // EVERY family the text can land on has to follow the same notch: the reader
6983
+ // sets one offset, and a Chinese family with three faces behind a variable
6984
+ // Latin one only moves where the Latin one has moved five times (issue #4).
6985
+ // The shapes are combined into the coarsest shared step and the range they
6986
+ // can all still express; the primary family alone is the answer only when the
6987
+ // stack names one family.
6988
+ // The shapes are combined into one notch that moves BOTH families evenly: the
6989
+ // first family draws the Latin text and the one behind it draws the Chinese, so
6990
+ // each is measured on its own script (issue #4).
6991
+ var families = stackFamilies(resolved);
6992
+ var shapes = [];
6993
+ // Comparing the families' ink costs a second set of canvas renders, so it runs
6994
+ // only when there IS a second family to be even with.
6995
+ var keepEven = families.length > 1;
6996
+ for (var index = 0; index < families.length && index < WEIGHT_STACK_FAMILIES; index += 1) {
6997
+ // A continuous family is measured THROUGH ITS ALIAS, on the fine candidate
6998
+ // grid: the axis answers at every notch, so the finest one that still moves
6999
+ // body text wins, and the range stops where the family's own axis does.
7000
+ var shape = familyWeightProfile(
7001
+ families[index],
7002
+ base,
7003
+ index === 0 && promoted ? alias : "",
7004
+ keepEven ? (index === 0 ? WEIGHT_SAMPLE_LATIN : WEIGHT_SAMPLE_FALLBACK) : null
7005
+ );
7006
+ if (shape !== null) shapes.push(shape);
7007
+ }
7008
+ // `base` is body text: the weight the two families' ink is compared at.
7009
+ var combined = shared.combineWeightProfiles(shapes, { base: shared.WEIGHT_BASE });
7010
+ if (combined !== null) {
7011
+ profile = combined;
7012
+ } else if (families.length > 0) {
7013
+ // Nothing measured (no canvas, no 2D context): the nominal window it is.
6587
7014
  profile = promoted
6588
7015
  ? shared.continuousWeightProfile()
6589
7016
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
package/lib/shared.cjs CHANGED
@@ -3254,6 +3254,294 @@ function ceilTo(value, step) {
3254
3254
  return Math.ceil(value / step) * step;
3255
3255
  }
3256
3256
 
3257
+ /**
3258
+ * How far apart two families may land on one notch before the stack refuses it.
3259
+ *
3260
+ * Measured on the reporter's pair (issue #4): one 100-weight notch adds +21.5%
3261
+ * ink to the Latin family and +23.9% to the Chinese one — 2.4 points apart, which
3262
+ * no reader notices. One 200-weight notch adds +44% and +51% — 7.4 points apart,
3263
+ * and that is the reported "one side suits, the other is too heavy". Five points
3264
+ * is the budget between those two observations.
3265
+ */
3266
+ var INK_BALANCE_SPREAD = 0.05;
3267
+
3268
+ /**
3269
+ * How far apart a pair may land when the strict budget would leave no control.
3270
+ *
3271
+ * Measured on the reporter's pair (issue #4): going one notch LIGHTER, the Latin
3272
+ * family loses 29.7% of its ink and the Chinese one 17.9% — 11.9 points apart, and
3273
+ * no finer notch makes it better, because the two families simply space their faces
3274
+ * differently. Under the strict budget the reader would get a weight control that
3275
+ * cannot go lighter at all (two positions). The wider budget is what keeps that
3276
+ * physical pair usable, and it is only consulted when the strict one is: a stack
3277
+ * that has four even positions never sees it.
3278
+ */
3279
+ var INK_BALANCE_SPREAD_WIDE = 0.15;
3280
+
3281
+ /** How many notches the evenness walk looks at, one way. */
3282
+ var WEIGHT_MAX_POSITIONS = 24;
3283
+
3284
+ /**
3285
+ * The least ink one notch has to add before a position is worth having.
3286
+ *
3287
+ * This is the bar `weightProfileFrom` already measured on this host: a 200-weight
3288
+ * step is +30% ink and a 100-weight step +16%, and 100 was not enough — a reader
3289
+ * could not see it. Five per cent would put a variable family's stack at twenty-odd
3290
+ * notches that each move a little (measured live: 29 positions of +6%), which is the
3291
+ * dead-control the coarse step exists to avoid. Fifteen keeps every pair the faces
3292
+ * do allow (the reported one tops out at +19%/+22%) and stops there.
3293
+ */
3294
+ var INK_BALANCE_STEP = 0.15;
3295
+
3296
+ /**
3297
+ * One family's ink at one weight.
3298
+ *
3299
+ * A family answers through `inkOf`, the sampler its own measurement already uses;
3300
+ * a weight it cannot measure — or a family that cannot be measured at all — answers
3301
+ * null and is left out of a comparison instead of counting as a zero.
3302
+ * @param {Object} entry - one measured family.
3303
+ * @param {number} weight - CSS weight.
3304
+ * @returns {number|null} the ink, or null when there is no measurement.
3305
+ */
3306
+ function entryInk(entry, weight) {
3307
+ if (entry === null || typeof entry !== "object") return null;
3308
+ if (typeof entry.inkOf !== "function") return null;
3309
+ var ink = null;
3310
+ try {
3311
+ ink = entry.inkOf(weight);
3312
+ } catch (error) {
3313
+ return null;
3314
+ }
3315
+ ink = Number(ink);
3316
+ if (!isFinite(ink) || ink <= 0) return null;
3317
+ return ink;
3318
+ }
3319
+
3320
+ /**
3321
+ * How much moving from one weight to another changes each family's ink.
3322
+ *
3323
+ * The share is relative to the family's OWN ink at `from`, so two families that
3324
+ * start out different sizes are still comparable: +20% means the same thing for
3325
+ * both. Families that cannot be measured on both sides are left out.
3326
+ * @param {Array<Object>} entries - the measured families.
3327
+ * @param {number} from - the weight the reader is at.
3328
+ * @param {number} to - the weight being compared.
3329
+ * @returns {number[]|null} the relative changes, or null when fewer than two
3330
+ * families can be measured.
3331
+ */
3332
+ function inkShares(entries, from, to) {
3333
+ var shares = [];
3334
+ for (var index = 0; index < entries.length; index += 1) {
3335
+ var before = entryInk(entries[index], from);
3336
+ var after = entryInk(entries[index], to);
3337
+ if (before === null || after === null) continue;
3338
+ shares.push((after - before) / before);
3339
+ }
3340
+ return shares.length < 2 ? null : shares;
3341
+ }
3342
+
3343
+ /**
3344
+ * How far apart the families land, and how little the quietest one moved.
3345
+ * @param {number[]} shares - one relative change per family.
3346
+ * @returns {{spread: number, smallest: number}} `spread` is the signed range, so
3347
+ * families moving in opposite directions count as far apart.
3348
+ */
3349
+ function shareSpread(shares) {
3350
+ var least = shares[0];
3351
+ var most = shares[0];
3352
+ var smallest = Math.abs(shares[0]);
3353
+ for (var index = 1; index < shares.length; index += 1) {
3354
+ if (shares[index] < least) least = shares[index];
3355
+ if (shares[index] > most) most = shares[index];
3356
+ var size = Math.abs(shares[index]);
3357
+ if (size < smallest) smallest = size;
3358
+ }
3359
+ return { spread: most - least, smallest: smallest };
3360
+ }
3361
+
3362
+ /**
3363
+ * The evenness verdict for one move of the stack.
3364
+ * @param {Array<Object>} entries - the measured families.
3365
+ * @param {number} from - the weight the move starts at.
3366
+ * @param {number} to - the weight it ends at.
3367
+ * @returns {{spread: number, smallest: number}|null} the verdict, or null when it
3368
+ * cannot be measured.
3369
+ */
3370
+ function evennessOf(entries, from, to) {
3371
+ var shares = inkShares(entries, from, to);
3372
+ if (shares === null) return null;
3373
+ return shareSpread(shares);
3374
+ }
3375
+
3376
+ /** Is one move of the stack both visible and even? */
3377
+ function moveIsEven(verdict, spread) {
3378
+ return verdict !== null && verdict.smallest >= INK_BALANCE_STEP && verdict.spread <= spread;
3379
+ }
3380
+
3381
+ /**
3382
+ * The shape a stack of families can follow together, chosen for EVENNESS.
3383
+ *
3384
+ * Issue #4 is not about the range: it is about two families being moved by one
3385
+ * offset and landing on faces that are not the same distance apart. The test is
3386
+ * therefore walked outwards from the neutral position, one notch at a time, and a
3387
+ * notch only survives when BOTH hold:
3388
+ * - every family's ink changes by at least `INK_BALANCE_STEP` (the notch is
3389
+ * visible), and
3390
+ * - the families land within `INK_BALANCE_SPREAD` of each other, measured both for
3391
+ * that one notch and for the whole way back to the neutral position — a stack
3392
+ * can be even notch by notch and still drift apart after two of them.
3393
+ *
3394
+ * The walk is what removes the synthetic-bold step without ever having to ask the
3395
+ * browser whether a weight is drawn or faked: above its last real face a family is
3396
+ * stroked thicker by the browser, which adds a much larger share of ink than a drawn
3397
+ * face does — so the notch where one family jumps and the other does not simply
3398
+ * fails the budget and the range stops in front of it.
3399
+ *
3400
+ * Among the shapes that survive, the one with the MOST positions wins: the visible
3401
+ * floor above is what makes a finer notch worth having, and more positions is more
3402
+ * control. (A single family is not put through this at all — it keeps the coarse
3403
+ * shape `weightProfileFrom` measured for it.)
3404
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}>} entries
3405
+ * one record per measured family.
3406
+ * @param {number} base - the weight the offset is added to (body text).
3407
+ * @param {number} low - the lowest offset every family can express.
3408
+ * @param {number} high - the highest offset every family can express.
3409
+ * @returns {{min: number, max: number, step: number}|null} the shape, or null when
3410
+ * no step leaves the stack two positions to move between.
3411
+ */
3412
+ function balancedStackShape(entries, base, low, high, spread) {
3413
+ var budget = typeof spread === "number" && spread > 0 ? spread : INK_BALANCE_SPREAD;
3414
+ var best = null;
3415
+ var bestCount = 1;
3416
+ var bestScore = Infinity;
3417
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3418
+ var step = WEIGHT_STEP_CANDIDATES[index];
3419
+ if (!(step > 0)) continue;
3420
+ var up = 0;
3421
+ var down = 0;
3422
+ // A walk is bounded: no useful shape has more than a couple of dozen
3423
+ // positions, and an unbounded one on a one-unit grid would cost hundreds of
3424
+ // canvas renders on the very first measurement.
3425
+ var limit = step * WEIGHT_MAX_POSITIONS;
3426
+ for (var position = step; position <= high && position <= limit; position += step) {
3427
+ if (!moveIsEven(evennessOf(entries, base + position - step, base + position), budget)) break;
3428
+ if (!moveIsEven(evennessOf(entries, base, base + position), budget)) break;
3429
+ up = position;
3430
+ }
3431
+ for (var back = step; back <= -low && back <= limit; back += step) {
3432
+ if (!moveIsEven(evennessOf(entries, base - back + step, base - back), budget)) break;
3433
+ if (!moveIsEven(evennessOf(entries, base, base - back), budget)) break;
3434
+ down = back;
3435
+ }
3436
+ var count = (up + down) / step + 1;
3437
+ if (count < 2) continue;
3438
+ // How close the two families are on one notch from the neutral position: a tie
3439
+ // between two shapes that move the stack over the same number of positions.
3440
+ var headroom = evennessOf(entries, base, base + step);
3441
+ if (headroom === null) headroom = evennessOf(entries, base, base - step);
3442
+ var score = headroom === null ? Infinity : headroom.spread;
3443
+ if (count > bestCount || (count === bestCount && score < bestScore)) {
3444
+ // -0 would read as a different number to a strict comparison.
3445
+ best = { min: down === 0 ? 0 : -down, max: up, step: step };
3446
+ bestCount = count;
3447
+ bestScore = score;
3448
+ }
3449
+ }
3450
+ return best;
3451
+ }
3452
+
3453
+ /**
3454
+ * One slider shape for a stack whose text can land on more than one family.
3455
+ *
3456
+ * A reader sets ONE offset; the browser then renders English with the first
3457
+ * family and Chinese with the fallback, each snapping that same requested value
3458
+ * to its own faces. When the fallback ships three faces and the primary is a
3459
+ * variable font, a step measured on the primary alone leaves the fallback
3460
+ * unchanged on most notches — the reported "one side suits, the other is too
3461
+ * heavy or too light" (issue #4: Inclusive Sans beside a three-face CJK family).
3462
+ *
3463
+ * The combined shape keeps every family moving on every notch, but the notch is
3464
+ * chosen for EVENNESS (see `balancedStackShape`): each family has to change by a
3465
+ * comparable share of its own ink, and the stack has to stay together over the
3466
+ * whole length of the range. Families that can report their ink (`inkOf`) get that
3467
+ * shape; a stack measured without a canvas keeps the plain shared-multiple shape,
3468
+ * which is also what a single family gets.
3469
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}|null>} profiles
3470
+ * one shape per family.
3471
+ * @param {{base?: number}} [options] `base` is the weight the offset is added to
3472
+ * (body text); it anchors the ink comparison.
3473
+ * @returns {{min: number, max: number, step: number}|null} the combined shape, or
3474
+ * null when nothing usable was measured.
3475
+ */
3476
+ function combineWeightProfiles(profiles, options) {
3477
+ var list = [];
3478
+ if (Array.isArray(profiles)) {
3479
+ for (var index = 0; index < profiles.length; index += 1) {
3480
+ var entry = profiles[index];
3481
+ if (entry === null || typeof entry !== "object") continue;
3482
+ if (!isFinite(entry.min) || !isFinite(entry.max) || !isFinite(entry.step)) continue;
3483
+ if (!(entry.step > 0)) continue;
3484
+ list.push(entry);
3485
+ }
3486
+ }
3487
+ if (list.length === 0) return null;
3488
+ if (list.length === 1) return { min: list[0].min, max: list[0].max, step: list[0].step };
3489
+ var step = list[0].step;
3490
+ for (var other = 1; other < list.length; other += 1) {
3491
+ step = commonStep(step, list[other].step);
3492
+ }
3493
+ var low = list[0].min;
3494
+ var high = list[0].max;
3495
+ for (var more = 1; more < list.length; more += 1) {
3496
+ low = Math.max(low, list[more].min);
3497
+ high = Math.min(high, list[more].max);
3498
+ }
3499
+ var settings = options === null || typeof options !== "object" ? {} : options;
3500
+ var base = isFinite(settings.base) ? Number(settings.base) : WEIGHT_BASE;
3501
+ // Every family that can report ink is put through the evenness walk; a stack whose
3502
+ // canvas could not answer keeps the plain shared-multiple shape.
3503
+ var measured = false;
3504
+ for (var scan = 0; scan < list.length; scan += 1) {
3505
+ if (typeof list[scan].inkOf === "function") measured = true;
3506
+ }
3507
+ if (measured) {
3508
+ var even = balancedStackShape(list, base, low, high);
3509
+ var positions = even === null ? 0 : (even.max - even.min) / even.step + 1;
3510
+ if (positions >= 3) return even;
3511
+ // The pair's own faces are spaced so unevenly that the strict budget leaves
3512
+ // no control; the wider one is what keeps a lighter position reachable.
3513
+ var relaxed = balancedStackShape(list, base, low, high, INK_BALANCE_SPREAD_WIDE);
3514
+ var relaxedPositions = relaxed === null ? 0 : (relaxed.max - relaxed.min) / relaxed.step + 1;
3515
+ if (relaxedPositions > positions) return relaxed;
3516
+ if (even !== null) return even;
3517
+ }
3518
+ var onGrid = { min: ceilTo(low, step), max: floorTo(high, step) };
3519
+ if (onGrid.max >= onGrid.min) return { min: onGrid.min, max: onGrid.max, step: step };
3520
+ var widest = list[0];
3521
+ for (var alt = 1; alt < list.length; alt += 1) {
3522
+ if (list[alt].max - list[alt].min > widest.max - widest.min) widest = list[alt];
3523
+ }
3524
+ return { min: widest.min, max: widest.max, step: widest.step };
3525
+ }
3526
+
3527
+ /**
3528
+ * The smallest candidate step that is a whole multiple of both inputs.
3529
+ *
3530
+ * Profiles come from one candidate list, so a shared multiple usually exists
3531
+ * there; two steps that share none fall back to the coarser one, which still
3532
+ * moves the coarser family on every notch.
3533
+ * @param {number} a - one step.
3534
+ * @param {number} b - another step.
3535
+ * @returns {number} the combined step.
3536
+ */
3537
+ function commonStep(a, b) {
3538
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3539
+ var candidate = WEIGHT_STEP_CANDIDATES[index];
3540
+ if (candidate % a === 0 && candidate % b === 0) return candidate;
3541
+ }
3542
+ return Math.max(a, b);
3543
+ }
3544
+
3257
3545
  /**
3258
3546
  * Does every position of `[min..max]` in `step` increments render differently?
3259
3547
  *
@@ -4837,6 +5125,16 @@ var shared = {
4837
5125
  WEIGHT_CSS_MIN: WEIGHT_CSS_MIN,
4838
5126
  WEIGHT_CSS_MAX: WEIGHT_CSS_MAX,
4839
5127
  weightProfileFrom: weightProfileFrom,
5128
+ combineWeightProfiles: combineWeightProfiles,
5129
+ balancedStackShape: balancedStackShape,
5130
+ evennessOf: evennessOf,
5131
+ INK_BALANCE_SPREAD: INK_BALANCE_SPREAD,
5132
+ INK_BALANCE_STEP: INK_BALANCE_STEP,
5133
+ INK_BALANCE_SPREAD_WIDE: INK_BALANCE_SPREAD_WIDE,
5134
+ WEIGHT_PROBE_LOW: WEIGHT_PROBE_LOW,
5135
+ WEIGHT_PROBE_HIGH: WEIGHT_PROBE_HIGH,
5136
+ WEIGHT_PROBE_STEP: WEIGHT_PROBE_STEP,
5137
+ GENERIC_FAMILIES: GENERIC_FAMILIES,
4840
5138
  weightStepRange: weightStepRange,
4841
5139
  isShorthandName: isShorthandName,
4842
5140
  isScaledToken: isScaledToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Font plugin for the DeepSeek Harness Web GUI: the conversation gets its own font, size, line height and weight, the interface font and weight follow it, code keeps its own axis with ligature control, and whole setups save as presets.",
5
5
  "type": "module",
6
6
  "icon": "icon.svg",