Skip to content

Commit c861434

Browse files
committed
Improve word-space detection in getTextContent
The gap between two glyphs was turned into a space using a hardcoded `trackingSpaceMin` of `0.102 * fontSize`. Depending on the font this was either too large or too small, producing fake spaces inside words ("Robe rt") or a space between every letter of letter-spaced text ("R E A S O N S"). The threshold is now derived from the font's space width (or the glyph's advance width for Type3 fonts, where the font size is meaningless) and raised for uniformly letter-spaced runs. A space wrongly inserted before the first glyph of such a run is retracted once the next gap confirms it, and the adaptive state is reset on spacing or text-matrix changes so it can't leak across sections. It fixes #18768 and #16752.
1 parent ac64bcf commit c861434

13 files changed

Lines changed: 341 additions & 24 deletions

src/core/evaluator.js

Lines changed: 159 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2418,6 +2418,12 @@ class PartialEvaluator {
24182418
spaceInFlowMin: 0,
24192419
spaceInFlowMax: 0,
24202420
trackingSpaceMin: Infinity,
2421+
useCharWidthThreshold: false,
2422+
wideSpaceMax: Infinity,
2423+
minAdvance: Infinity,
2424+
pendingSpaceIndex: -1,
2425+
pendingSpaceAdvance: 0,
2426+
pendingSpaceExtraCharId: -1,
24212427
negativeSpaceMax: -Infinity,
24222428
notASpace: -Infinity,
24232429
transform: null,
@@ -2483,21 +2489,37 @@ class PartialEvaluator {
24832489
// even if one is present in the text stream.
24842490
const NOT_A_SPACE_FACTOR = 0.03;
24852491

2492+
// A reported space wider than fontSize * MAX_SPACE_WIDTH_FACTOR is treated
2493+
// as unreliable metrics, so trackingSpaceMin isn't derived from it.
2494+
const MAX_SPACE_WIDTH_FACTOR = 1 / 3;
2495+
24862496
// A negative white < fontSize * NEGATIVE_SPACE_FACTOR induces
24872497
// a break (a new chunk of text is created).
24882498
// It doesn't change anything when the text is copied but
24892499
// it improves potential mismatch between text layer and canvas.
24902500
const NEGATIVE_SPACE_FACTOR = -0.2;
24912501

2492-
// A white with a width in [fontSize * MIN_FACTOR; fontSize * MAX_FACTOR]
2493-
// is a space which will be inserted in the current flow of words.
2502+
// A white with a width in [trackingSpaceMin; fontSize * MAX_FACTOR] is a
2503+
// space which will be inserted in the current flow of words.
24942504
// If the width is outside of this range then the flow is broken
24952505
// (which means a new span in the text layer).
24962506
// It's useful to adjust the best as possible the span in the layer
24972507
// to what is displayed in the canvas.
2498-
const SPACE_IN_FLOW_MIN_FACTOR = 0.102;
24992508
const SPACE_IN_FLOW_MAX_FACTOR = 0.6;
25002509

2510+
// When every gap within a run is wider than the space threshold (e.g.
2511+
// letter-spaced text), the threshold is raised to this multiple of the
2512+
// smallest gap, capped at fontSize * WIDE_SPACE_MAX_FACTOR, so the gaps
2513+
// between letters of a single word aren't turned into spaces.
2514+
const WIDE_SPACE_MULT = 1.3;
2515+
const WIDE_SPACE_MAX_FACTOR = 0.4;
2516+
2517+
// For fonts without usable space metrics (Type3), the space threshold is
2518+
// derived from the current glyph's own advance width instead of the font
2519+
// size, since the latter is unreliable when the font matrix does the
2520+
// scaling.
2521+
const CHAR_WIDTH_SPACE_FACTOR = 0.25;
2522+
25012523
// If a char is too high/too low compared to the previous we just create
25022524
// a new chunk.
25032525
// If the advance isn't in the +/-VERTICAL_SHIFT_RATIO * height range then
@@ -2516,6 +2538,7 @@ class PartialEvaluator {
25162538
const preprocessor = new EvaluatorPreprocessor(stream, xref, stateManager);
25172539

25182540
let textState, currentTextState;
2541+
let lastFakeSpaceExtraCharId = -1;
25192542

25202543
function pushWhitespace({
25212544
width = 0,
@@ -2610,10 +2633,21 @@ class PartialEvaluator {
26102633
textContentItem.textAdvanceScale = scaleCtmX * scaleLineX;
26112634

26122635
const { fontSize } = textState;
2613-
textContentItem.trackingSpaceMin = fontSize * TRACKING_SPACE_FACTOR;
2636+
2637+
let trackingFactor = TRACKING_SPACE_FACTOR;
2638+
if (!font.isType3Font) {
2639+
const spaceEm = font.spaceWidth / 1000;
2640+
if (spaceEm > 0 && spaceEm <= MAX_SPACE_WIDTH_FACTOR) {
2641+
trackingFactor = Math.max(0.5 * spaceEm, NOT_A_SPACE_FACTOR);
2642+
}
2643+
}
2644+
textContentItem.trackingSpaceMin = fontSize * trackingFactor;
2645+
textContentItem.useCharWidthThreshold = font.isType3Font;
2646+
textContentItem.spaceInFlowMin = fontSize * trackingFactor;
2647+
textContentItem.wideSpaceMax = fontSize * WIDE_SPACE_MAX_FACTOR;
2648+
resetAdaptiveSpacing();
26142649
textContentItem.notASpace = fontSize * NOT_A_SPACE_FACTOR;
26152650
textContentItem.negativeSpaceMax = fontSize * NEGATIVE_SPACE_FACTOR;
2616-
textContentItem.spaceInFlowMin = fontSize * SPACE_IN_FLOW_MIN_FACTOR;
26172651
textContentItem.spaceInFlowMax = fontSize * SPACE_IN_FLOW_MAX_FACTOR;
26182652
textContentItem.hasEOL = false;
26192653

@@ -2690,6 +2724,78 @@ class PartialEvaluator {
26902724
];
26912725
}
26922726

2727+
function getTrackingSpaceMin(glyphWidth) {
2728+
if (textContentItem.useCharWidthThreshold && glyphWidth) {
2729+
return (
2730+
Math.abs(glyphWidth * textState.textHScale) * CHAR_WIDTH_SPACE_FACTOR
2731+
);
2732+
}
2733+
return textContentItem.trackingSpaceMin;
2734+
}
2735+
2736+
function getSpaceThreshold(trackingSpaceMin) {
2737+
const { minAdvance, wideSpaceMax } = textContentItem;
2738+
if (
2739+
minAdvance > trackingSpaceMin &&
2740+
minAdvance < WIDE_SPACE_MULT * trackingSpaceMin
2741+
) {
2742+
return Math.min(WIDE_SPACE_MULT * minAdvance, wideSpaceMax);
2743+
}
2744+
return trackingSpaceMin;
2745+
}
2746+
2747+
function resetAdaptiveSpacing() {
2748+
textContentItem.minAdvance = Infinity;
2749+
textContentItem.pendingSpaceIndex = -1;
2750+
textContentItem.pendingSpaceAdvance = 0;
2751+
textContentItem.pendingSpaceExtraCharId = -1;
2752+
}
2753+
2754+
function recordPendingSpace(advance, textOrientation) {
2755+
if (textContentItem.minAdvance < Infinity) {
2756+
return;
2757+
}
2758+
textContentItem.pendingSpaceIndex = textContentItem.str.length - 1;
2759+
textContentItem.pendingSpaceAdvance = textOrientation * advance;
2760+
textContentItem.pendingSpaceExtraCharId = lastFakeSpaceExtraCharId;
2761+
}
2762+
2763+
function resolvePendingSpace(
2764+
advance,
2765+
textOrientation,
2766+
trackingSpaceMin,
2767+
spaceThreshold
2768+
) {
2769+
if (textContentItem.pendingSpaceIndex < 0) {
2770+
return;
2771+
}
2772+
const { pendingSpaceAdvance } = textContentItem;
2773+
const forwardAdvance = textOrientation * advance;
2774+
const baseline = Math.min(pendingSpaceAdvance, forwardAdvance);
2775+
if (
2776+
baseline > trackingSpaceMin / WIDE_SPACE_MULT &&
2777+
forwardAdvance <= spaceThreshold &&
2778+
pendingSpaceAdvance <= spaceThreshold
2779+
) {
2780+
textContentItem.str.splice(textContentItem.pendingSpaceIndex, 1);
2781+
if (textContentItem.pendingSpaceExtraCharId >= 0) {
2782+
intersector?.removeExtraChar(textContentItem.pendingSpaceExtraCharId);
2783+
}
2784+
}
2785+
textContentItem.pendingSpaceIndex = -1;
2786+
textContentItem.pendingSpaceExtraCharId = -1;
2787+
}
2788+
2789+
function recordMinAdvance(advance, textOrientation, trackingSpaceMin) {
2790+
const forwardAdvance = textOrientation * advance;
2791+
if (
2792+
forwardAdvance > trackingSpaceMin &&
2793+
forwardAdvance < textContentItem.minAdvance
2794+
) {
2795+
textContentItem.minAdvance = forwardAdvance;
2796+
}
2797+
}
2798+
26932799
function compareWithLastPosition(glyphWidth) {
26942800
const currentTransform = getCurrentTextTransform();
26952801
let posX = currentTransform[4];
@@ -2808,7 +2914,15 @@ class PartialEvaluator {
28082914
resetLastChars();
28092915
}
28102916

2811-
if (advanceY <= textOrientation * textContentItem.trackingSpaceMin) {
2917+
const trackingSpaceMin = getTrackingSpaceMin(glyphWidth);
2918+
const spaceThreshold = getSpaceThreshold(trackingSpaceMin);
2919+
resolvePendingSpace(
2920+
advanceY,
2921+
textOrientation,
2922+
trackingSpaceMin,
2923+
spaceThreshold
2924+
);
2925+
if (advanceY <= textOrientation * spaceThreshold) {
28122926
if (shouldAddWhitepsace()) {
28132927
// The space is very thin, hence it deserves to have its own span in
28142928
// order to avoid too much shift between the canvas and the text
@@ -2831,9 +2945,12 @@ class PartialEvaluator {
28312945
pushWhitespace({ height: Math.abs(advanceY) });
28322946
} else {
28332947
textContentItem.height += advanceY;
2948+
recordPendingSpace(advanceY, textOrientation);
28342949
}
28352950
}
28362951

2952+
recordMinAdvance(advanceY, textOrientation, trackingSpaceMin);
2953+
28372954
if (Math.abs(advanceX) > textContentItem.width * VERTICAL_SHIFT_RATIO) {
28382955
flushTextContentItem();
28392956
}
@@ -2888,7 +3005,15 @@ class PartialEvaluator {
28883005
resetLastChars();
28893006
}
28903007

2891-
if (advanceX <= textOrientation * textContentItem.trackingSpaceMin) {
3008+
const trackingSpaceMin = getTrackingSpaceMin(glyphWidth);
3009+
const spaceThreshold = getSpaceThreshold(trackingSpaceMin);
3010+
resolvePendingSpace(
3011+
advanceX,
3012+
textOrientation,
3013+
trackingSpaceMin,
3014+
spaceThreshold
3015+
);
3016+
if (advanceX <= textOrientation * spaceThreshold) {
28923017
if (shouldAddWhitepsace()) {
28933018
// The space is very thin, hence it deserves to have its own span in
28943019
// order to avoid too much shift between the canvas and the text
@@ -2907,9 +3032,12 @@ class PartialEvaluator {
29073032
pushWhitespace({ width: Math.abs(advanceX) });
29083033
} else {
29093034
textContentItem.width += advanceX;
3035+
recordPendingSpace(advanceX, textOrientation);
29103036
}
29113037
}
29123038

3039+
recordMinAdvance(advanceX, textOrientation, trackingSpaceMin);
3040+
29133041
if (Math.abs(advanceY) > textContentItem.height * VERTICAL_SHIFT_RATIO) {
29143042
flushTextContentItem();
29153043
}
@@ -3092,14 +3220,15 @@ class PartialEvaluator {
30923220
}
30933221

30943222
function addFakeSpaces(width, transf, textOrientation) {
3223+
lastFakeSpaceExtraCharId = -1;
30953224
if (
30963225
textOrientation * textContentItem.spaceInFlowMin <= width &&
30973226
width <= textOrientation * textContentItem.spaceInFlowMax
30983227
) {
30993228
if (textContentItem.initialized) {
31003229
resetLastChars();
31013230
textContentItem.str.push(" ");
3102-
intersector?.addExtraChar(" ");
3231+
lastFakeSpaceExtraCharId = intersector?.addExtraChar(" ") ?? -1;
31033232
}
31043233
return false;
31053234
}
@@ -3140,6 +3269,7 @@ class PartialEvaluator {
31403269

31413270
textContent.items.push(runBidiTransform(textContentItem));
31423271
textContentItem.initialized = false;
3272+
resetAdaptiveSpacing();
31433273
textContentItem.str.length = 0;
31443274
}
31453275

@@ -3216,7 +3346,10 @@ class PartialEvaluator {
32163346
textState.textRise = args[0];
32173347
break;
32183348
case OPS.setHScale:
3219-
textState.textHScale = args[0] / 100;
3349+
if (textState.textHScale !== args[0] / 100) {
3350+
textState.textHScale = args[0] / 100;
3351+
resetAdaptiveSpacing();
3352+
}
32203353
break;
32213354
case OPS.setLeading:
32223355
textState.leading = args[0];
@@ -3251,12 +3384,19 @@ class PartialEvaluator {
32513384
args[5]
32523385
);
32533386
updateAdvanceScale();
3387+
resetAdaptiveSpacing();
32543388
break;
32553389
case OPS.setCharSpacing:
3256-
textState.charSpacing = args[0];
3390+
if (textState.charSpacing !== args[0]) {
3391+
textState.charSpacing = args[0];
3392+
resetAdaptiveSpacing();
3393+
}
32573394
break;
32583395
case OPS.setWordSpacing:
3259-
textState.wordSpacing = args[0];
3396+
if (textState.wordSpacing !== args[0]) {
3397+
textState.wordSpacing = args[0];
3398+
resetAdaptiveSpacing();
3399+
}
32603400
break;
32613401
case OPS.beginText:
32623402
textState.textMatrix = IDENTITY_MATRIX.slice();
@@ -3328,8 +3468,14 @@ class PartialEvaluator {
33283468
self.ensureStateFont(stateManager.state);
33293469
continue;
33303470
}
3331-
textState.wordSpacing = args[0];
3332-
textState.charSpacing = args[1];
3471+
if (
3472+
textState.wordSpacing !== args[0] ||
3473+
textState.charSpacing !== args[1]
3474+
) {
3475+
textState.wordSpacing = args[0];
3476+
textState.charSpacing = args[1];
3477+
resetAdaptiveSpacing();
3478+
}
33333479
textState.carriageReturn();
33343480
buildTextContentItem({
33353481
chars: args[2],

src/core/fonts.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3503,6 +3503,11 @@ class Font {
35033503
return shadow(this, "_spaceWidth", width || this.defaultWidth);
35043504
}
35053505

3506+
// Estimated width of the space glyph, in glyph-space units (1/1000 em).
3507+
get spaceWidth() {
3508+
return this._spaceWidth;
3509+
}
3510+
35063511
/**
35073512
* @private
35083513
*/

0 commit comments

Comments
 (0)