Skip to content

Commit

Permalink
implement overflow-wrap (aka word-break: break-word)
Browse files Browse the repository at this point in the history
  • Loading branch information
chearon committed Apr 14, 2024
1 parent 5553c32 commit 89be0c0
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 36 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Following are rules that work or will work soon. Shorthand properties are not li
| <code>unicode-&zwj;bidi</code> | | 🚧&zwj;&nbsp;Planned |
| <code>vertical-&zwj;align</code> | `baseline`, `middle`, `sub`, `super`, `text-top`, `text-bottom`, `%`, `px` etc, `top`, `bottom` |&zwj;&nbsp;Works |
| <code>white-&zwj;space</code> | `normal`, `nowrap`, `pre`, `pre-wrap`, `pre-line` |&zwj;&nbsp;Works |
| <code>word-&zwj;break</code> | `break-word`, `normal` | 🚧&zwj;&nbsp;Planned |
| <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 |

## Block formatting

Expand Down
98 changes: 66 additions & 32 deletions src/layout-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ export class Run extends RenderItem {
return isWsCollapsible(this.style.whiteSpace);
}

wrapsOverflowAnywhere(mode: 'min-content' | 'max-content' | 'normal') {
if (mode === 'min-content') {
return this.style.overflowWrap === 'anywhere'
|| this.style.wordBreak === 'break-word';
} else {
return this.style.overflowWrap === 'anywhere'
|| this.style.overflowWrap === 'break-word'
|| this.style.wordBreak === 'break-word';
}
}

isRun(): this is Run {
return true;
}
Expand Down Expand Up @@ -1559,17 +1570,18 @@ class ContiguousBoxBuilder {
}

interface IfcMark {
position: number,
isBreak: boolean,
isBreakForced: boolean,
isItemStart: boolean,
inlinePre: Inline | null,
inlinePost: Inline | null,
block: BlockContainer | null,
advance: number,
trailingWs: number,
itemIndex: number,
split: (this: IfcMark, mark: IfcMark) => void
position: number;
isBreak: boolean;
isGraphemeBreak: boolean;
isBreakForced: boolean;
isItemStart: boolean;
inlinePre: Inline | null;
inlinePost: Inline | null;
block: BlockContainer | null;
advance: number;
trailingWs: number;
itemIndex: number;
split: (this: IfcMark, mark: IfcMark) => void;
}

function isink(c: string) {
Expand Down Expand Up @@ -1968,7 +1980,7 @@ export class Paragraph {
}
}

createMarkIterator() {
createMarkIterator(ctx: LayoutContext) {
// Inline iterator
const inlineIterator = createInlineIterator(this.ifc);
let inline = inlineIterator.next();
Expand All @@ -1983,13 +1995,16 @@ export class Paragraph {
let itemIndex = -1;
let itemMeasureState: MeasureState | undefined;
let itemMark = 0;
// Grapheme iterator
let graphemeBreakMark = 0;
// Other
const end = this.length();

const next = (): {done: true} | {done: false, value: IfcMark} => {
const mark: IfcMark = {
position: Math.min(inlineMark, itemMark, breakMark),
position: Math.min(inlineMark, itemMark, breakMark, graphemeBreakMark),
isBreak: false,
isGraphemeBreak: false,
isBreakForced: false,
isItemStart: false,
inlinePre: null,
Expand Down Expand Up @@ -2048,6 +2063,9 @@ export class Paragraph {
if (!inline.done) {
if (inline.value.state === 'text') {
inlineMark += inline.value.item.length;
if (!inline.value.item.wrapsOverflowAnywhere(ctx.mode)) {
graphemeBreakMark = inlineMark;
}
inline = inlineIterator.next();
} else if (inline.value.state === 'break') {
mark.isBreak = true;
Expand Down Expand Up @@ -2094,6 +2112,11 @@ export class Paragraph {
}
}

if (graphemeBreakMark === mark.position && this.ifc.hasText()) {
mark.isGraphemeBreak = true;
graphemeBreakMark = nextGraphemeBreak(this.string, graphemeBreakMark);
}

if (!inline.done && inlineMark === mark.position && inline.value.state === 'breakspot') {
inline = inlineIterator.next();
}
Expand Down Expand Up @@ -2135,6 +2158,7 @@ export class Paragraph {
const lines = [];
let floatsInWord = [];
let blockOffset = bfc.cbBlockStart;
let lineHasWord = false;

// Optimization: here we assume that (1) doTextLayout will never be called
// on the same ifc with a 'normal' mode twice and (2) that when the mode is
Expand All @@ -2156,9 +2180,10 @@ export class Paragraph {
bfc.fctx?.postLine(line, true);
blockOffset += blockSize;
bfc.getLocalVacancyForLine(bfc, blockOffset, blockSize, vacancy);
lineHasWord = false;
};

for (const mark of this.createMarkIterator()) {
for (const mark of this.createMarkIterator(ctx)) {
const parent = parents[parents.length - 1] || this.ifc;
const item = this.brokenItems[mark.itemIndex];

Expand Down Expand Up @@ -2230,9 +2255,13 @@ export class Paragraph {
for (const p of parents) p.nshaped += 1;
}

// Either a Unicode soft wrap, before/after an inline-block, or cluster
// boundary enabled by overflow-wrap
const isBreak = mark.isBreak || mark.isGraphemeBreak;

if (
// Is a unicode soft wrap or before/after inline-block
mark.isBreak && (
// Is an opportunity for soft wrapping
isBreak && (
// There is content on the hypothetical line and CSS allows wrapping
wouldHaveContent && !nowrap ||
// A <br> or preserved \n always creates a new line
Expand Down Expand Up @@ -2294,23 +2323,28 @@ export class Paragraph {
}
}

line.addCandidates(candidates, mark.position);
width.concat(candidates.width);
height.concat(candidates.height);

candidates.clearContents();
lastBreakMark = mark;

for (const float of floatsInWord) {
const fctx = bfc.ensureFloatContext(blockOffset);
layoutFloatBox(float, ctx);
fctx.placeFloat(width.forFloat(), false, float);
}
if (floatsInWord.length) floatsInWord = [];
// Add at each normal wrapping opportunity. Inside overflow-wrap
// segments, we add each character while the line doesn't have a word.
if (mark.isBreak || !lineHasWord) {
if (mark.isBreak) lineHasWord = true;
line.addCandidates(candidates, mark.position);
width.concat(candidates.width);
height.concat(candidates.height);

candidates.clearContents();
lastBreakMark = mark;

for (const float of floatsInWord) {
const fctx = bfc.ensureFloatContext(blockOffset);
layoutFloatBox(float, ctx);
fctx.placeFloat(width.forFloat(), false, float);
}
if (floatsInWord.length) floatsInWord = [];

if (mark.isBreakForced) {
finishLine(line);
lines.push(line = new Linebox(mark.position, this));
if (mark.isBreakForced) {
finishLine(line);
lines.push(line = new Linebox(mark.position, this));
}
}
}

Expand Down
14 changes: 13 additions & 1 deletion src/parse-css.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ declaration
/ float_dec
/ clear_dec
/ z_index_dec
/ word_break_dec
/ overflow_wrap_dec
/ name:property ':' S* value:expr {
let r = {};
r['_' + name] = value;
Expand Down Expand Up @@ -750,7 +752,17 @@ clear_dec

z_index_dec
= 'z-index'i S* ':' S* zIndex:('auto' / NUMBER / default) {
return {zIndex}
return {zIndex};
}

word_break_dec
= 'word-break'i S* ':' S* wordBreak:('normal' / 'break-word' / default) {
return {wordBreak};
}

overflow_wrap_dec
= ('overflow-wrap'i / 'word-wrap'i) S* ':' S* overflowWrap:('normal' / 'anywhere' / 'break-word' / default) {
return {overflowWrap};
}

width_dec
Expand Down
14 changes: 12 additions & 2 deletions src/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ export interface DeclaredStyle {
float?: Float | Inherited | Initial;
clear?: Clear | Inherited | Initial;
zIndex?: number | 'auto' | Inherited | Initial;
wordBreak?: 'break-word' | 'normal' | Inherited | Initial;
overflowWrap?: 'anywhere' | 'break-word' | 'normal' | Inherited | Initial;
}

export const EMPTY_STYLE: DeclaredStyle = {};
Expand Down Expand Up @@ -256,6 +258,8 @@ export class Style implements ComputedStyle {
float: ComputedStyle['float'];
clear: ComputedStyle['clear'];
zIndex: ComputedStyle['zIndex'];
wordBreak: ComputedStyle['wordBreak'];
overflowWrap: ComputedStyle['overflowWrap'];

constructor(style: ComputedStyle) {
this.whiteSpace = style.whiteSpace;
Expand Down Expand Up @@ -306,6 +310,8 @@ export class Style implements ComputedStyle {
this.float = style.float;
this.clear = style.clear;
this.zIndex = style.zIndex;
this.wordBreak = style.wordBreak;
this.overflowWrap = style.overflowWrap;
}

getLineHeight() {
Expand Down Expand Up @@ -534,7 +540,9 @@ const initialPlainStyle: ComputedStyle = Object.freeze({
textAlign: 'start',
float: 'none',
clear: 'none',
zIndex: 'auto'
zIndex: 'auto',
wordBreak: 'normal',
overflowWrap: 'normal'
});

export const initialStyle = new Style(initialPlainStyle);
Expand Down Expand Up @@ -590,7 +598,9 @@ const inheritedStyle: InheritedStyleDefinitions = Object.freeze({
textAlign: true,
float: false,
clear: false,
zIndex: false
zIndex: false,
wordBreak: true,
overflowWrap: true
});

type UaDeclaredStyles = {[tagName: string]: DeclaredStyle};
Expand Down
98 changes: 98 additions & 0 deletions test/text.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ describe('Lines', function () {
registerFontAsset('Noto/NotoSansHebrew-Regular.ttf');
registerFontAsset('Raleway/Raleway-Regular.ttf');
registerFontAsset('LigatureSymbolsWithSpaces/LigatureSymbolsWithSpaces.ttf');
registerFontAsset('Ahem/Ahem.ttf');
});

after(function () {
Expand All @@ -457,6 +458,7 @@ describe('Lines', function () {
unregisterFontAsset('Noto/NotoSansHebrew-Regular.ttf');
unregisterFontAsset('Raleway/Raleway-Regular.ttf');
unregisterFontAsset('LigatureSymbolsWithSpaces/LigatureSymbolsWithSpaces.ttf');
unregisterFontAsset('Ahem/Ahem.ttf');
});

afterEach(logIfFailed);
Expand Down Expand Up @@ -1291,6 +1293,102 @@ describe('Lines', function () {
expect(ifc.paragraph.lineboxes[1].height()).to.be.approximately(29.984, 0.001);
});
});

describe('Overflow-wrap', function () {
it('breaks inlines that have the rule, not ones that don\'t', function () {
this.layout(`
<div id="t" style="width: 50px; font: 10px Ahem;">
guided by
<span style="overflow-wrap: anywhere;">voices</span>
</div>
`);

/** @type import('../src/layout-flow').IfcInline[] */
const [ifc] = this.get('#t').children;

expect(ifc.paragraph.lineboxes.length).to.equal(4);
expect(ifc.paragraph.lineboxes[0].startOffset).to.equal(0);
expect(ifc.paragraph.lineboxes[1].startOffset).to.equal(8);
expect(ifc.paragraph.lineboxes[2].startOffset).to.equal(11);
expect(ifc.paragraph.lineboxes[3].startOffset).to.equal(16);
});

it('word-break: break-word functions as anywhere', function () {
this.layout(`
<div id="t" style="font: 10px Ahem; word-break: break-word; width: 90px;">
Is it springtime today yet?
</div>
`);

/** @type import('../src/layout-flow').IfcInline[] */
const [ifc] = this.get('#t').children;

expect(ifc.paragraph.lineboxes.length).to.equal(4);
expect(ifc.paragraph.lineboxes[0].startOffset).to.equal(0);
expect(ifc.paragraph.lineboxes[1].startOffset).to.equal(7);
expect(ifc.paragraph.lineboxes[2].startOffset).to.equal(16);
expect(ifc.paragraph.lineboxes[3].startOffset).to.equal(24);
});

it('places floats after broken words', function () {
// https://bugs.webkit.org/show_bug.cgi?id=272534
// ab | cd◾️ | ef
this.layout(`
<div id="t1" style="font: 10px/1 Ahem; width: 25px; overflow-wrap: anywhere;">
abcd<div id="t2" style="float: left; width: 5px; height: 5px;"></div>ef
</div>
`);

/** @type import('../src/layout-flow').IfcInline[] */
const [ifc] = this.get('#t1').children;

expect(ifc.paragraph.lineboxes.length).to.equal(3);
expect(ifc.paragraph.lineboxes[0].startOffset).to.equal(0);
expect(ifc.paragraph.lineboxes[1].startOffset).to.equal(3);
expect(ifc.paragraph.lineboxes[2].startOffset).to.equal(5);

expect(this.get('#t2').contentArea.x).to.equal(0);
expect(this.get('#t2').contentArea.y).to.equal(10);
});

it('measures and places inlines inside break-word correctly', function () {
// big | [ro | om] | bar
this.layout(`
<div id="t" style="font: 10px Ahem; width: 30px; overflow-wrap: anywhere;">
big <span style="padding: 0 10px;">room</span> bar
</div>
`);

/** @type import('../src/layout-flow').IfcInline[] */
const [ifc] = this.get('#t').children;

expect(ifc.paragraph.lineboxes.length).to.equal(4);
expect(ifc.paragraph.lineboxes[0].startOffset).to.equal(0);
expect(ifc.paragraph.lineboxes[1].startOffset).to.equal(5);
expect(ifc.paragraph.lineboxes[2].startOffset).to.equal(7);
expect(ifc.paragraph.lineboxes[3].startOffset).to.equal(10);
});

it('anywhere affects min-content', function () {
this.layout(`
<div style="width: 0;">
<div id="t" style="font: 10px Ahem; overflow-wrap: anywhere; float: left;">abcde</div>
</div>
`);

expect(this.get('#t').contentArea.width).to.equal(10);
});

it('break-word doesn\'t affect min-content', function () {
this.layout(`
<div style="width: 0;">
<div id="t" style="font: 10px Ahem; overflow-wrap: break-word; float: left;">abcde</div>
</div>
`);

expect(this.get('#t').contentArea.width).to.equal(50);
});
});
});

describe('Vertical Align', function () {
Expand Down

0 comments on commit 89be0c0

Please sign in to comment.