Skip to content

Commit e8819f3

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 4781194 commit e8819f3

16 files changed

Lines changed: 391 additions & 30 deletions

src/core/evaluator.js

Lines changed: 163 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, TRACKING_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,81 @@ 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+
if (textContentItem.useCharWidthThreshold) {
2791+
return;
2792+
}
2793+
const forwardAdvance = textOrientation * advance;
2794+
if (
2795+
forwardAdvance > trackingSpaceMin &&
2796+
forwardAdvance < textContentItem.minAdvance
2797+
) {
2798+
textContentItem.minAdvance = forwardAdvance;
2799+
}
2800+
}
2801+
26932802
function compareWithLastPosition(glyphWidth) {
26942803
const currentTransform = getCurrentTextTransform();
26952804
let posX = currentTransform[4];
@@ -2722,6 +2831,7 @@ class PartialEvaluator {
27222831
let lastPosY = textContentItem.prevTransform[5];
27232832

27242833
if (lastPosX === posX && lastPosY === posY) {
2834+
textContentItem.minAdvance = Infinity;
27252835
return true;
27262836
}
27272837

@@ -2808,7 +2918,15 @@ class PartialEvaluator {
28082918
resetLastChars();
28092919
}
28102920

2811-
if (advanceY <= textOrientation * textContentItem.trackingSpaceMin) {
2921+
const trackingSpaceMin = getTrackingSpaceMin(glyphWidth);
2922+
const spaceThreshold = getSpaceThreshold(trackingSpaceMin);
2923+
resolvePendingSpace(
2924+
advanceY,
2925+
textOrientation,
2926+
trackingSpaceMin,
2927+
spaceThreshold
2928+
);
2929+
if (advanceY <= textOrientation * spaceThreshold) {
28122930
if (shouldAddWhitepsace()) {
28132931
// The space is very thin, hence it deserves to have its own span in
28142932
// order to avoid too much shift between the canvas and the text
@@ -2831,9 +2949,12 @@ class PartialEvaluator {
28312949
pushWhitespace({ height: Math.abs(advanceY) });
28322950
} else {
28332951
textContentItem.height += advanceY;
2952+
recordPendingSpace(advanceY, textOrientation);
28342953
}
28352954
}
28362955

2956+
recordMinAdvance(advanceY, textOrientation, trackingSpaceMin);
2957+
28372958
if (Math.abs(advanceX) > textContentItem.width * VERTICAL_SHIFT_RATIO) {
28382959
flushTextContentItem();
28392960
}
@@ -2888,7 +3009,15 @@ class PartialEvaluator {
28883009
resetLastChars();
28893010
}
28903011

2891-
if (advanceX <= textOrientation * textContentItem.trackingSpaceMin) {
3012+
const trackingSpaceMin = getTrackingSpaceMin(glyphWidth);
3013+
const spaceThreshold = getSpaceThreshold(trackingSpaceMin);
3014+
resolvePendingSpace(
3015+
advanceX,
3016+
textOrientation,
3017+
trackingSpaceMin,
3018+
spaceThreshold
3019+
);
3020+
if (advanceX <= textOrientation * spaceThreshold) {
28923021
if (shouldAddWhitepsace()) {
28933022
// The space is very thin, hence it deserves to have its own span in
28943023
// order to avoid too much shift between the canvas and the text
@@ -2907,9 +3036,12 @@ class PartialEvaluator {
29073036
pushWhitespace({ width: Math.abs(advanceX) });
29083037
} else {
29093038
textContentItem.width += advanceX;
3039+
recordPendingSpace(advanceX, textOrientation);
29103040
}
29113041
}
29123042

3043+
recordMinAdvance(advanceX, textOrientation, trackingSpaceMin);
3044+
29133045
if (Math.abs(advanceY) > textContentItem.height * VERTICAL_SHIFT_RATIO) {
29143046
flushTextContentItem();
29153047
}
@@ -3092,14 +3224,15 @@ class PartialEvaluator {
30923224
}
30933225

30943226
function addFakeSpaces(width, transf, textOrientation) {
3227+
lastFakeSpaceExtraCharId = -1;
30953228
if (
30963229
textOrientation * textContentItem.spaceInFlowMin <= width &&
30973230
width <= textOrientation * textContentItem.spaceInFlowMax
30983231
) {
30993232
if (textContentItem.initialized) {
31003233
resetLastChars();
31013234
textContentItem.str.push(" ");
3102-
intersector?.addExtraChar(" ");
3235+
lastFakeSpaceExtraCharId = intersector?.addExtraChar(" ") ?? -1;
31033236
}
31043237
return false;
31053238
}
@@ -3140,6 +3273,7 @@ class PartialEvaluator {
31403273

31413274
textContent.items.push(runBidiTransform(textContentItem));
31423275
textContentItem.initialized = false;
3276+
resetAdaptiveSpacing();
31433277
textContentItem.str.length = 0;
31443278
}
31453279

@@ -3216,7 +3350,10 @@ class PartialEvaluator {
32163350
textState.textRise = args[0];
32173351
break;
32183352
case OPS.setHScale:
3219-
textState.textHScale = args[0] / 100;
3353+
if (textState.textHScale !== args[0] / 100) {
3354+
textState.textHScale = args[0] / 100;
3355+
resetAdaptiveSpacing();
3356+
}
32203357
break;
32213358
case OPS.setLeading:
32223359
textState.leading = args[0];
@@ -3251,12 +3388,19 @@ class PartialEvaluator {
32513388
args[5]
32523389
);
32533390
updateAdvanceScale();
3391+
resetAdaptiveSpacing();
32543392
break;
32553393
case OPS.setCharSpacing:
3256-
textState.charSpacing = args[0];
3394+
if (textState.charSpacing !== args[0]) {
3395+
textState.charSpacing = args[0];
3396+
resetAdaptiveSpacing();
3397+
}
32573398
break;
32583399
case OPS.setWordSpacing:
3259-
textState.wordSpacing = args[0];
3400+
if (textState.wordSpacing !== args[0]) {
3401+
textState.wordSpacing = args[0];
3402+
resetAdaptiveSpacing();
3403+
}
32603404
break;
32613405
case OPS.beginText:
32623406
textState.textMatrix = IDENTITY_MATRIX.slice();
@@ -3328,8 +3472,14 @@ class PartialEvaluator {
33283472
self.ensureStateFont(stateManager.state);
33293473
continue;
33303474
}
3331-
textState.wordSpacing = args[0];
3332-
textState.charSpacing = args[1];
3475+
if (
3476+
textState.wordSpacing !== args[0] ||
3477+
textState.charSpacing !== args[1]
3478+
) {
3479+
textState.wordSpacing = args[0];
3480+
textState.charSpacing = args[1];
3481+
resetAdaptiveSpacing();
3482+
}
33333483
textState.carriageReturn();
33343484
buildTextContentItem({
33353485
chars: args[2],

src/core/fonts.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3462,10 +3462,7 @@ class Font {
34623462
return builder.toArray();
34633463
}
34643464

3465-
/**
3466-
* @private
3467-
*/
3468-
get _spaceWidth() {
3465+
get spaceWidth() {
34693466
// trying to estimate space character width
34703467
const possibleSpaceReplacements = ["space", "minus", "one", "i", "I"];
34713468
let width;
@@ -3500,7 +3497,7 @@ class Font {
35003497
break; // the non-zero width found
35013498
}
35023499
}
3503-
return shadow(this, "_spaceWidth", width || this.defaultWidth);
3500+
return shadow(this, "spaceWidth", width || this.defaultWidth);
35043501
}
35053502

35063503
/**
@@ -3552,7 +3549,7 @@ class Font {
35523549
if (glyphName === "") {
35533550
// Ensure that other relevant glyph properties are also updated
35543551
// (fixes issue18059.pdf).
3555-
width ||= this._spaceWidth;
3552+
width ||= this.spaceWidth;
35563553
unicode = String.fromCharCode(fontCharCode);
35573554
}
35583555
}

0 commit comments

Comments
 (0)