Skip to content

Commit

Permalink
implement pixel snapping at any zoom/dpr
Browse files Browse the repository at this point in the history
fixes #16
  • Loading branch information
chearon committed Dec 30, 2024
1 parent bde76cc commit 57d1cbe
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 57 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ project adheres to [Semantic Versioning](http://semver.org/).
### Added
* Added support for the `zoom` property
* Support for multiple styles on an element
* Support for hardware pixel snapping (#16)

### Fixed

Expand Down
10 changes: 6 additions & 4 deletions site/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ async function render(html) {
const ctx = canvas.getContext('2d');
const cssWidth = wrap.getBoundingClientRect().width;
const cssHeight = wrap.getBoundingClientRect().height;
const dpxWidth = Math.ceil(cssWidth * window.devicePixelRatio);
const dpxHeight = Math.ceil(cssHeight * window.devicePixelRatio);

await flow.loadNotoFonts(documentElement);
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
canvas.width = cssWidth * window.devicePixelRatio;
canvas.height = cssHeight * window.devicePixelRatio;
canvas.style.width = `${dpxWidth / window.devicePixelRatio}px`;
canvas.style.height = `${dpxHeight / window.devicePixelRatio}px`;
canvas.width = dpxWidth;
canvas.height = dpxHeight;

const {r, g, b, a} = documentElement.style.backgroundColor;
canvasLabel.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${a})`;
Expand Down
26 changes: 26 additions & 0 deletions src/layout-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,32 @@ export class BoxArea {
this.blockSize = height;
}

snapPixels() {
let width, height;

if (!this.parent) {
throw new Error(`Cannot absolutify area for ${this.box.id}, parent was never set`);
}

if (this.parent.writingMode === 'vertical-lr') {
width = this.blockSize;
height = this.inlineSize;
} else if (this.parent.writingMode === 'vertical-rl') {
width = this.blockSize;
height = this.inlineSize;
} else { // 'horizontal-tb'
width = this.inlineSize;
height = this.blockSize;
}

const x = this.lineLeft;
const y = this.blockStart;
this.lineLeft = Math.round(this.lineLeft);
this.blockStart = Math.round(this.blockStart);
this.inlineSize = Math.round(x + width) - this.lineLeft;
this.blockSize = Math.round(y + height) - this.blockStart;
}

repr(indent = 0) {
const {width: w, height: h, x, y} = this;
return ' '.repeat(indent) + `⚃ Area ${this.box.id}: ${w}${h} @${x},${y}`;
Expand Down
11 changes: 7 additions & 4 deletions src/layout-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,17 +992,20 @@ export class BlockContainer extends Box {
}

postprocess() {
this.borderArea.absolutify();

if (this.style.position === 'relative') {
this.borderArea.x += this.getRelativeHorizontalShift();
this.borderArea.y += this.getRelativeVerticalShift();
}

this.borderArea.absolutify();
if (this.paddingArea !== this.borderArea) this.paddingArea.absolutify();
if (this.contentArea !== this.paddingArea) this.contentArea.absolutify();

for (const c of this.children) c.postprocess();

this.borderArea.snapPixels();
if (this.paddingArea !== this.borderArea) this.paddingArea.snapPixels();
if (this.contentArea !== this.paddingArea) this.contentArea.snapPixels();
}

doTextLayout(ctx: LayoutContext) {
Expand Down Expand Up @@ -1521,8 +1524,6 @@ export class IfcInline extends Inline {
}

postprocess() {
super.postprocess();

this.paragraph.destroy();

if (this.hasPositionedInline()) {
Expand Down Expand Up @@ -1584,6 +1585,8 @@ export class IfcInline extends Inline {
}
}
}

super.postprocess();
}

shouldLayoutContent() {
Expand Down
6 changes: 6 additions & 0 deletions src/layout-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,9 @@ class ContiguousBoxBuilder {

open(inline: Inline, linebox: Linebox, naturalStart: boolean, start: number, blockOffset: number) {
const box = this.opened.get(inline);

start = Math.round(start);

if (box) {
box.end = start;
} else {
Expand All @@ -1526,6 +1529,9 @@ class ContiguousBoxBuilder {

close(inline: Inline, naturalEnd: boolean, end: number) {
const box = this.opened.get(inline);

end = Math.round(end);

if (box) {
const list = this.closed.get(inline);
box.end = end;
Expand Down
9 changes: 7 additions & 2 deletions src/paint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,13 @@ function paintInlineBackground(
b.fillColor = bgc;
const x = containingBlock.x + Math.min(start, end);
const y = containingBlock.y + blockOffset - ascender - extraTop;
const width = Math.abs(start - end);
const height = ascender + descender + extraTop + extraBottom;
// TODO: since vertical padding is added above, hardware pixel snapping has
// to happen here. But block containers are snapped during layout, so it'd
// be more consistent to do it there. To be more consistent with the specs,
// and hopefully clean up the code, I should start making "continuations"
// (Firefox) of inlines, or create fragments out of them (Chrome)
const width = Math.round(Math.abs(start - end));
const height = Math.round(ascender + descender + extraTop + extraBottom);
b.rect(x, y, width, height);
}

Expand Down
13 changes: 9 additions & 4 deletions src/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,11 @@ export class Style {
return length * this.zoom;
}

private usedBorderLength(length: number) {
length *= this.zoom;
return length > 0 && length < 1 ? 1 : Math.floor(length);
}

private usedMaybeLength<T>(length: T) {
return typeof length === 'number' ? this.usedLength(length) : length;
}
Expand All @@ -401,10 +406,10 @@ export class Style {
this.display = style.display;
this.direction = style.direction;
this.writingMode = style.writingMode;
this.borderTopWidth = this.usedLength(style.borderTopWidth);
this.borderRightWidth = this.usedLength(style.borderRightWidth);
this.borderBottomWidth = this.usedLength(style.borderBottomWidth);
this.borderLeftWidth = this.usedLength(style.borderLeftWidth);
this.borderTopWidth = this.usedBorderLength(style.borderTopWidth);
this.borderRightWidth = this.usedBorderLength(style.borderRightWidth);
this.borderBottomWidth = this.usedBorderLength(style.borderBottomWidth);
this.borderLeftWidth = this.usedBorderLength(style.borderLeftWidth);
this.borderTopStyle = style.borderTopStyle;
this.borderRightStyle = style.borderRightStyle;
this.borderBottomStyle = style.borderBottomStyle;
Expand Down
38 changes: 19 additions & 19 deletions test/flow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1349,7 +1349,7 @@ describe('Flow', function () {
`);
/** @type import('../src/layout-flow').BlockContainer */
const block = this.get('#t');
expect(block.contentArea.width).to.equal(152.0625);
expect(block.contentArea.width).to.equal(152);
});

it('lays out text under min-content constraint', function () {
Expand All @@ -1360,7 +1360,7 @@ describe('Flow', function () {
`);
/** @type import('../src/layout-flow').BlockContainer */
const block = this.get('#t');
expect(block.contentArea.width).to.equal(66.6953125);
expect(block.contentArea.width).to.equal(67);
});

it('lays out text no bigger than containing block', function () {
Expand All @@ -1386,8 +1386,8 @@ describe('Flow', function () {
const t1 = this.get('#t1');
/** @type import('../src/layout-flow').BlockContainer */
const t2 = this.get('#t2');
expect(t1.contentArea.width).to.equal(152.0625);
expect(t2.contentArea.width).to.equal(152.0625);
expect(t1.contentArea.width).to.equal(152);
expect(t2.contentArea.width).to.equal(152);
});

it('lays out nested floats under min-content constraint', function () {
Expand All @@ -1402,8 +1402,8 @@ describe('Flow', function () {
const t1 = this.get('#t1');
/** @type import('../src/layout-flow').BlockContainer */
const t2 = this.get('#t2');
expect(t1.contentArea.width).to.equal(66.6953125);
expect(t2.contentArea.width).to.equal(66.6953125);
expect(t1.contentArea.width).to.equal(67);
expect(t2.contentArea.width).to.equal(67);
});

it('lays out nested floats no bigger than containing block', function () {
Expand Down Expand Up @@ -1434,7 +1434,7 @@ describe('Flow', function () {

/** @type import('../src/layout-flow').BlockContainer */
const t = this.get('#t');
expect(t.contentArea.width).to.equal(85.3984375);
expect(t.contentArea.width).to.equal(85);
});

it('chooses the largest nested float if larger than largest word', function () {
Expand All @@ -1450,7 +1450,7 @@ describe('Flow', function () {

/** @type import('../src/layout-flow').BlockContainer */
const t = this.get('#t');
expect(t.contentArea.width).to.equal(85.3984375);
expect(t.contentArea.width).to.equal(85);
});

it('sets nested float heights correctly under min-content', function () {
Expand Down Expand Up @@ -1542,7 +1542,7 @@ describe('Flow', function () {

/** @type import('../src/layout-flow').BlockContainer */
const t = this.get('#t');
expect(t.contentArea.width).to.equal(185.3984375);
expect(t.contentArea.width).to.equal(185);
});
});
});
Expand Down Expand Up @@ -1600,12 +1600,12 @@ describe('Flow', function () {
/** @type import('../src/layout-flow').IfcInline[] */
const [ifc] = this.get('div').children;

expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].start).to.equal(87.03125);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].end).to.equal(133.28125);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].start).to.equal(87);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].end).to.equal(133);
expect(ifc.paragraph.brokenItems[1].x).to.equal(87.03125);

expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].start).to.equal(139.7265625);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].end).to.equal(211.7734375);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].start).to.equal(140);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].end).to.equal(212);
expect(ifc.paragraph.brokenItems[3].x).to.equal(139.7265625);

expect(ifc.paragraph.backgroundBoxes.get(this.get('#t3'))[0].blockOffset).to.equal(13.74609375);
Expand Down Expand Up @@ -1634,12 +1634,12 @@ describe('Flow', function () {
/** @type import('../src/layout-flow').IfcInline[] */
const [ifc] = this.get('div').children;

expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].start).to.equal(88.03125);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].end).to.equal(134.28125);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].start).to.equal(88);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t1'))[0].end).to.equal(134);
expect(ifc.paragraph.brokenItems[1].x).to.equal(88.03125);

expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].start).to.equal(140.7265625);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].end).to.equal(212.7734375);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].start).to.equal(141);
expect(ifc.paragraph.backgroundBoxes.get(this.get('#t2'))[0].end).to.equal(213);
expect(ifc.paragraph.brokenItems[3].x).to.equal(140.7265625);

expect(ifc.paragraph.backgroundBoxes.get(this.get('#t3'))[0].blockOffset).to.equal(14.74609375);
Expand Down Expand Up @@ -1703,7 +1703,7 @@ describe('Flow', function () {

/** @type import('../src/layout-flow').BlockContainer */
const block = this.get('#t');
expect(block.borderArea.x).to.be.approximately(151.414, 0.001);
expect(block.borderArea.x).to.equal(151);
expect(block.borderArea.y).to.equal(10);
});

Expand All @@ -1723,7 +1723,7 @@ describe('Flow', function () {

/** @type import('../src/layout-flow').BlockContainer */
const block = this.get('#t');
expect(block.borderArea.x).to.be.approximately(141.414, 0.001);
expect(block.borderArea.x).to.equal(141);
expect(block.borderArea.y).to.equal(0);
});
});
Expand Down
47 changes: 47 additions & 0 deletions test/paint.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -535,4 +535,51 @@ describe('Painting', function () {
{t: 'popClip'}
]);
});

// TODO: would go better in a general box.spec.js
describe('Pixel snapping', function () {
it('snaps the border box', function () {
this.layout(`
<div style="width: 10px;">
<div style="background-color: #111; margin-top: 0.5px; height: 1.4px;"></div>
<div style="background-color: #222; height: 1.1px;"></div>
</div>
`);

expect(this.paint().getCalls()).to.deep.equal([
{t: 'rect', x: 0, y: 1, width: 10, height: 1, fillColor: '#111'},
{t: 'rect', x: 0, y: 2, width: 10, height: 1, fillColor: '#222'}
]);
});

it('rounds boxes based on their dependent\'s unrounded coordinates', function () {
this.layout(`
<div style="width: 10px;">
<div style="position: relative; left: 0.4px; background-color: #9e1;">
<div style="position: relative; left: 0.4px; height: 1px; background-color: #e19;">
</div>
</div>
</div>
`);

expect(this.paint().getCalls()).to.deep.equal([
{t: 'rect', x: 0, y: 0, width: 10, height: 1, fillColor: '#9e1'},
{t: 'rect', x: 1, y: 0, width: 10, height: 1, fillColor: '#e19'}
]);
});

it('snaps inline boxes', function () {
this.layout(`
<div style="font-size: 10.2px;">
2<span style="padding-left: 0.3px; background-clip: content-box; background-color: #321;">3</span>
</div>
`);

expect(this.paint().getCalls()).to.deep.equal([
{t: 'text', x: 0, y: 8.159999999999998, text: '2', fillColor: '#000'},
{t: 'rect', x: 11, y: 0, width: 10, height: 10, fillColor: '#321'},
{t: 'text', x: 10.5, y: 8.159999999999998, text: '3', fillColor: '#000'},
]);
});
});
});
Loading

0 comments on commit 57d1cbe

Please sign in to comment.