Skip to content

Commit 76a5712

Browse files
committed
add support for the word-spacing css property
1 parent 496383f commit 76a5712

10 files changed

Lines changed: 1169 additions & 772 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Following are rules that work or will work soon. Shorthand properties are not li
4444
| <code>vertical-&zwj;align</code> | `baseline`, `middle`, `sub`, `super`, `text-top`, `text-bottom`, `%`, `px` etc, `top`, `bottom` |&zwj;&nbsp;Works |
4545
| <code>white-&zwj;space</code> | `normal`, `nowrap`, `pre`, `pre-wrap`, `pre-line` |&zwj;&nbsp;Works |
4646
| <code>word-&zwj;break</code><br><code>overflow-&zwj;wrap</code>,<code>word-&zwj;wrap</code> | `break-word`, `normal`<br>`anywhere`, `normal` |&zwj;&nbsp;Works |
47+
| <code>word-&zwj;spacing</code> | `normal`, `%`, `number` |&zwj;&nbsp;Works |
4748

4849
## Block formatting
4950

src/layout-box.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,16 @@ export abstract class Box extends TreeNode {
154154
hasSoftHyphen: 1 << 10,
155155
hasNewlines: 1 << 11,
156156
hasSoftWrap: 1 << 12,
157-
// 13..14: propagation bits: Inline <- Inline
158-
hasPaintedInlines: 1 << 13,
159-
hasSizedInline: 1 << 14,
160-
// 15: propagation bits: Inline <- Break, Inline, ReplacedBox
161-
hasBreakInlineOrReplaced: 1 << 15,
162-
// 16..17: propagation bits: Inline <- FormattingBox
163-
hasFloatOrReplaced: 1 << 16,
164-
hasInlineBlocks: 1 << 17,
165-
// 18..31: if you take them, remove them from PROPAGATES_TO_INLINE_BITS
157+
hasWordSpacing: 1 << 13,
158+
// 14..15: propagation bits: Inline <- Inline
159+
hasPaintedInlines: 1 << 14,
160+
hasSizedInline: 1 << 15,
161+
// 16: propagation bits: Inline <- Break, Inline, ReplacedBox
162+
hasBreakInlineOrReplaced: 1 << 16,
163+
// 17..18: propagation bits: Inline <- FormattingBox
164+
hasFloatOrReplaced: 1 << 17,
165+
hasInlineBlocks: 1 << 18,
166+
// 19..31: if you take them, remove them from PROPAGATES_TO_INLINE_BITS
166167
};
167168

168169
/**

src/layout-flow.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,43 @@ export class BlockContainerOfInlines extends BlockContainerBase {
932932
return true;
933933
}
934934

935+
getRunIndex(layout: Layout, ci: number) {
936+
let l = this.treeStart, r = this.treeFinal;
937+
938+
while (true) {
939+
const i = Math.floor((l + r) / 2);
940+
941+
if (layout.tree[i].isRun()) {
942+
if (ci < layout.tree[i].textStart) {
943+
r = i - 1;
944+
} else if (ci >= layout.tree[i].textEnd) {
945+
l = i + 1;
946+
} else {
947+
return i;
948+
}
949+
} else if (layout.tree[i].isInline()) {
950+
if (ci < layout.tree[i].textStart) {
951+
r = i - 1;
952+
} else {
953+
l = i + 1;
954+
}
955+
} else { // inline-block, float, or image. pick a side.
956+
let ml = i;
957+
let mr = i;
958+
959+
while (mr < r && !layout.tree[mr].isRun() && !layout.tree[mr].isInline()) mr++;
960+
while (ml > l && !layout.tree[ml].isRun() && !layout.tree[ml].isInline()) ml--;
961+
962+
const item = layout.tree[mr];
963+
if ((item.isRun() || item.isInline()) && ci < item.textStart) {
964+
r = ml;
965+
} else {
966+
l = mr;
967+
}
968+
}
969+
}
970+
}
971+
935972
loggingEnabled() {
936973
return Boolean(this.bitfield & Box.BITS.enableLogging);
937974
}
@@ -1303,6 +1340,10 @@ export class Inline extends Box {
13031340
return this.bitfield & Box.BITS.hasSoftWrap;
13041341
}
13051342

1343+
hasWordSpacing() {
1344+
return this.bitfield & Box.BITS.hasWordSpacing;
1345+
}
1346+
13061347
hasFloatOrReplaced() {
13071348
return this.bitfield & Box.BITS.hasFloatOrReplaced;
13081349
}

src/layout-text.ts

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ function isNewline(c: string) {
5151
return c === '\n';
5252
}
5353

54+
function isWordSeparator(c: string): boolean {
55+
// Specs say to treat U+1361, Ethiopic Word Space, as a word separator, but
56+
// digitized Ethiopic usually has spaces. Browsers don't support it either;
57+
// doing so would be complicated since the ink has to be centered. Specs also
58+
// say to handle Agean, Ugaritic, and Phoenician, but these are extinct.
59+
return c === ' ' || c === '\u00a0';
60+
}
61+
5462
function graphemeBoundaries(text: string, index: number) {
5563
const graphemeEnd = nextGraphemeBreak(text, index);
5664
const graphemeStart = previousGraphemeBreak(text, graphemeEnd);
@@ -122,6 +130,10 @@ export class Run extends TreeNode {
122130
parent.bitfield |= Run.TEXT_BITS;
123131
}
124132

133+
if (this.style.wordSpacing !== 'normal') {
134+
parent.bitfield |= Box.BITS.hasWordSpacing;
135+
}
136+
125137
for (let i = this.textStart; i < this.textEnd; i++) {
126138
const code = paragraph.charCodeAt(i);
127139

@@ -561,6 +573,44 @@ export const EmptyInlineMetrics: Readonly<InlineMetrics> = Object.freeze({
561573
descenderBox: 0
562574
});
563575

576+
class WordIterator {
577+
item: ShapedItem;
578+
textEnd: number;
579+
state: MeasureState;
580+
/* out */
581+
start: number;
582+
end: number;
583+
x: number;
584+
w: number;
585+
done: boolean;
586+
587+
constructor(item: ShapedItem, textStart: number, textEnd: number) {
588+
this.item = item;
589+
this.textEnd = textEnd;
590+
this.state = item.createMeasureState();
591+
this.start = textStart;
592+
this.end = textStart;
593+
this.x = 0;
594+
this.w = 0;
595+
this.done = false;
596+
item.measure(textStart, 1, this.state);
597+
this.next();
598+
}
599+
600+
next() {
601+
const s = this.item.ifc.text;
602+
while (this.end < this.textEnd && isWordSeparator(s[this.end])) this.end++;
603+
this.start = this.end;
604+
while (this.end < this.textEnd && !isWordSeparator(s[this.end])) this.end++;
605+
if (this.start < this.end) {
606+
this.x = this.item.measure(this.start, 1, this.state).advance;
607+
this.w = this.item.measure(this.end, 1, this.state).advance;
608+
} else {
609+
this.done = true;
610+
}
611+
}
612+
}
613+
564614
export class ShapedItem implements IfcRenderItem {
565615
ifc: BlockContainerOfInlines;
566616
face: LoadedFontFace;
@@ -804,6 +854,20 @@ export class ShapedItem implements IfcRenderItem {
804854
return ci > this.offset && ci < this.end();
805855
}
806856

857+
createWordIterator(textStart: number, textEnd: number) {
858+
return new WordIterator(this, textStart, textEnd);
859+
}
860+
861+
mayHaveModifiedWordSepGlyphs(layout: Layout) {
862+
if (this.inlines.length) {
863+
return this.inlines[this.inlines.length - 1].hasWordSpacing();
864+
} else {
865+
const rootInline = layout.tree[this.ifc.treeStart + 1];
866+
if (!rootInline.isInline()) throw new Error('Assertion failed');
867+
return rootInline.hasWordSpacing();
868+
}
869+
}
870+
807871
// only use this in debugging or tests
808872
text() {
809873
return this.ifc.text.slice(this.offset, this.offset + this.length);
@@ -1832,6 +1896,63 @@ function postShapeLoadHyphens(ifc: BlockContainerOfInlines, items: ShapedItem[])
18321896
}
18331897
}
18341898

1899+
function postShapeAddWordSpacing(
1900+
layout: Layout,
1901+
ifc: BlockContainerOfInlines,
1902+
items: ShapedItem[],
1903+
inlineIndex: number,
1904+
itemIndex: number,
1905+
endItem: number
1906+
) {
1907+
while (inlineIndex <= ifc.treeFinal && itemIndex < endItem) {
1908+
const box = layout.tree[inlineIndex];
1909+
if (box.isRun() && box.style.wordSpacing !== 'normal') {
1910+
while ( // Forward to the item that owns the textOffset
1911+
itemIndex + 1 < endItem &&
1912+
items[itemIndex + 1].offset <= box.textStart
1913+
) itemIndex++;
1914+
1915+
while (
1916+
itemIndex < endItem &&
1917+
items[itemIndex].offset < box.textEnd
1918+
) {
1919+
const item = items[itemIndex];
1920+
const {wordSpacing, fontSize} = box.style;
1921+
let addPx = typeof wordSpacing === 'number' ? wordSpacing
1922+
: wordSpacing.value / 100 * fontSize;
1923+
1924+
const addUnits = addPx * item.face.hbface.upem / fontSize;
1925+
1926+
// TODO this isn't... super great, iterating the same glyphs array
1927+
// multiple times if multiple inlines cover it, but this is typically
1928+
// extremely fast, plus inline word-spacing is probably rare. Still,
1929+
// if/when looking at generalizing glyph/span walking, try to improve.
1930+
for (let i = 0; i < item.glyphs.length; i = nextCluster(item.glyphs, i)) {
1931+
const cl = item.glyphs[i + G_CL];
1932+
if (isWordSeparator(item.ifc.text[cl])) {
1933+
if (cl >= box.textStart && cl < box.textEnd) {
1934+
item.glyphs[i + G_AX] += addUnits;
1935+
}
1936+
}
1937+
}
1938+
1939+
if (items[itemIndex].end() <= box.textEnd) {
1940+
itemIndex++;
1941+
} else {
1942+
break; // spans into next inline
1943+
}
1944+
}
1945+
} else if (box.isBox()) {
1946+
if (box.isInline() && box.hasWordSpacing()) {
1947+
// descend
1948+
} else {
1949+
inlineIndex = box.treeFinal; // skip
1950+
}
1951+
}
1952+
inlineIndex++;
1953+
}
1954+
}
1955+
18351956
function shapePart(
18361957
ifc: BlockContainerOfInlines,
18371958
offset: number,
@@ -1988,6 +2109,9 @@ export function createIfcShapedItems(
19882109
items.sort((a, b) => a.offset - b.offset);
19892110

19902111
if (inlineRoot.hasSoftHyphen()) postShapeLoadHyphens(ifc, items);
2112+
if (inlineRoot.hasWordSpacing()) {
2113+
postShapeAddWordSpacing(layout, ifc, items, ifc.treeStart + 2, 0, items.length);
2114+
}
19912115

19922116
return items;
19932117
}
@@ -2199,16 +2323,26 @@ export function getIfcContribution(
21992323
return contribution;
22002324
}
22012325

2202-
function splitItem(ifc: BlockContainerOfInlines, itemIndex: number, offset: number) {
2326+
function splitItem(
2327+
layout: Layout,
2328+
ifc: BlockContainerOfInlines,
2329+
itemIndex: number,
2330+
offset: number
2331+
) {
22032332
const left = ifc.items[itemIndex];
22042333
const {needsReshape, right} = left.split(offset - left.offset);
22052334

2335+
ifc.items.splice(itemIndex + 1, 0, right);
2336+
22062337
if (needsReshape) {
2338+
const inlineIndex = ifc.getRunIndex(layout, left.offset);
22072339
left.reshape(true);
22082340
right.reshape(false);
2341+
if (left.mayHaveModifiedWordSepGlyphs(layout)) {
2342+
postShapeAddWordSpacing(layout, ifc, ifc.items, inlineIndex, itemIndex, itemIndex + 2);
2343+
}
22092344
}
22102345

2211-
ifc.items.splice(itemIndex + 1, 0, right);
22122346
if (ifc.text[offset - 1] === '\u00ad' /* softHyphenCharacter */) {
22132347
const hyphen = getHyphen(left)?.glyphs;
22142348
if (hyphen?.length) {
@@ -2387,7 +2521,7 @@ export function createIfcLineboxes(
23872521
const lastBreakMarkItem = ifc.items[lastBreakMark.itemIndex];
23882522

23892523
if (lastBreakMarkItem?.hasCharacterInside(lastBreakMark.position)) {
2390-
splitItem(ifc, lastBreakMark.itemIndex, lastBreakMark.position);
2524+
splitItem(layout, ifc, lastBreakMark.itemIndex, lastBreakMark.position);
23912525
lastBreakMark.split(mark);
23922526
candidates.unshift(ifc.items[lastBreakMark.itemIndex]);
23932527
// Stamp the brand new line with its metrics, since the only other

src/paint-canvas.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,6 @@ export default class CanvasPaintBackend implements PaintBackend {
153153
}
154154

155155
fastText(x: number, y: number, item: ShapedItem, textStart: number, textEnd: number) {
156-
const text = item.ifc.sliceRenderText(this.layout, item, textStart, textEnd);
157156
const {r, g, b, a} = this.fillColor;
158157
this.ctx.save();
159158
this.ctx.direction = item.attrs.level & 1 ? 'rtl' : 'ltr';
@@ -165,7 +164,24 @@ export default class CanvasPaintBackend implements PaintBackend {
165164
}
166165
this.ctx.font = this.font?.toFontString(this.fontSize) || '';
167166
this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
168-
this.ctx.fillText(text, x, y);
167+
if (item.mayHaveModifiedWordSepGlyphs(this.layout)) {
168+
if (item.attrs.level & 1) {
169+
const state = item.createMeasureState();
170+
item.measure(textStart, 1, state);
171+
x += item.measure(textEnd, 1, state).advance;
172+
}
173+
const words = item.createWordIterator(textStart, textEnd);
174+
for (; !words.done; words.next()) {
175+
const text = item.ifc.sliceRenderText(this.layout, item, words.start, words.end);
176+
x += item.attrs.level & 1 ? -words.x : words.x;
177+
if (item.attrs.level & 1) x -= words.w;
178+
this.ctx.fillText(text, x, y);
179+
if (!(item.attrs.level & 1)) x += words.w;
180+
}
181+
} else {
182+
const text = item.ifc.sliceRenderText(this.layout, item, textStart, textEnd);
183+
this.ctx.fillText(text, x, y);
184+
}
169185
this.ctx.restore();
170186
}
171187

@@ -194,7 +210,14 @@ export default class CanvasPaintBackend implements PaintBackend {
194210
this.ctx.restore();
195211
}
196212

197-
text(x: number, y: number, item: ShapedItem, totalTextStart: number, totalTextEnd: number, isColorBoundary: boolean) {
213+
text(
214+
x: number,
215+
y: number,
216+
item: ShapedItem,
217+
totalTextStart: number,
218+
totalTextEnd: number,
219+
isColorBoundary: boolean
220+
) {
198221
if (isColorBoundary) {
199222
const {
200223
startGlyphStart,

src/paint-svg.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ export default class SvgPaintBackend implements PaintBackend {
9595
}
9696

9797
text(x: number, y: number, item: ShapedItem, textStart: number, textEnd: number) {
98-
const text = item.ifc.sliceRenderText(this.layout, item, textStart, textEnd).trim();
9998
const {r, g, b, a} = this.fillColor;
10099
const color = `rgba(${r}, ${g}, ${b}, ${a})`;
101100
const style = this.style({
@@ -114,7 +113,20 @@ export default class SvgPaintBackend implements PaintBackend {
114113
// be the only way to get around that.
115114
if (this.direction === 'rtl') x += item.measure().advance;
116115

117-
this.main += `<text x="${x}" y="${y}" style="${encode(style)}" fill="${color}" ${clipPath}>${encode(text)}</text>`;
116+
if (item.mayHaveModifiedWordSepGlyphs(this.layout)) {
117+
const words = item.createWordIterator(textStart, textEnd);
118+
if (item.attrs.level & 1) x += item.measure().advance;
119+
for (; !words.done; words.next()) {
120+
const text = item.ifc.sliceRenderText(this.layout, item, words.start, words.end);
121+
x += item.attrs.level & 1 ? -words.x : words.x;
122+
if (item.attrs.level & 1) x -= words.w;
123+
this.main += `<text x="${x}" y="${y}" style="${encode(style)}" fill="${color}" ${clipPath}>${encode(text)}</text>`;
124+
if (!(item.attrs.level & 1)) x += words.w;
125+
}
126+
} else {
127+
const text = item.ifc.sliceRenderText(this.layout, item, textStart, textEnd);
128+
this.main += `<text x="${x}" y="${y}" style="${encode(style)}" fill="${color}" ${clipPath}>${encode(text)}</text>`;
129+
}
118130
this.usedFonts.set(item.face.url.href, item.face);
119131
}
120132

0 commit comments

Comments
 (0)