dropflow
Version:
A small CSS2 document renderer built from specifications
120 lines (119 loc) • 4.4 kB
JavaScript
function encode(s) {
return s.replaceAll('&', '&').replaceAll('<', '<');
}
function camelToKebab(camel) {
return camel.replace(/[A-Z]/g, s => '-' + s.toLowerCase());
}
;
function intersectRects(rects) {
const rect = { ...rects[0] };
for (let i = 1; i < rects.length; i++) {
const right = rect.x + rect.width;
const bottom = rect.y + rect.height;
rect.x = Math.max(rect.x, rects[i].x);
rect.y = Math.max(rect.y, rects[i].y);
rect.width = Math.min(right, rects[i].x + rects[i].width) - rect.x;
rect.height = Math.min(bottom, rects[i].y + rects[i].height) - rect.y;
}
return rect;
}
function createId() {
let ret = '';
for (let i = 0; i < 10; i++) {
ret += String.fromCharCode(0x61 /* 'a' */ + Math.floor(Math.random() * 26));
}
return ret;
}
export default class SvgPaintBackend {
main;
defs;
clips;
fillColor;
strokeColor;
lineWidth;
direction;
font;
fontSize;
usedFonts;
constructor() {
this.main = '';
this.defs = '';
this.clips = [];
this.fillColor = { r: 0, g: 0, b: 0, a: 0 };
this.strokeColor = { r: 0, g: 0, b: 0, a: 0 };
this.lineWidth = 0;
this.direction = 'ltr';
this.font = undefined;
this.fontSize = 0;
this.usedFonts = new Map();
}
style(style) {
return Object.entries(style).map(([prop, value]) => {
return `${camelToKebab(prop)}: ${value}`;
}).join('; ');
}
edge(x, y, length, side) {
const { r, g, b, a } = this.strokeColor;
const lw = this.lineWidth;
const lw2 = lw / 2;
const width = side === 'top' || side === 'bottom' ? length : lw;
const height = side === 'left' || side === 'right' ? length : lw;
const backgroundColor = `rgba(${r}, ${g}, ${b}, ${a})`;
const rect = this.clips.at(-1);
const clipPath = rect ? `clip-path="url(#${rect.id}) "` : ' ';
x = side === 'left' || side === 'right' ? x - lw2 : x;
y = side === 'top' || side === 'bottom' ? y - lw2 : y;
this.main += `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${backgroundColor}" ${clipPath}/>`;
}
text(x, y, item, textStart, textEnd) {
const text = item.paragraph.string.slice(textStart, textEnd).trim();
const { r, g, b, a } = this.fillColor;
const color = `rgba(${r}, ${g}, ${b}, ${a})`;
const style = this.style({
font: this.font?.toFontString(this.fontSize) ?? '',
whiteSpace: 'pre',
direction: this.direction,
unicodeBidi: 'bidi-override'
});
const rect = this.clips.at(-1);
const clipPath = rect ? `clip-path="url(#${rect.id}) "` : ' ';
// We set the direction property above so that unicode-bidi: bidi-override
// works and the direction set in CSS is the direction used in the outputted
// SVG. But that also causes the text to be right-aligned, and this seems to
// be the only way to get around that.
if (this.direction === 'rtl')
x += item.measure().advance;
this.main += `<text x="${x}" y="${y}" style="${encode(style)}" fill="${color}" ${clipPath}>${encode(text)}</text>`;
this.usedFonts.set(item.face.url.href, item.face);
}
rect(x, y, w, h) {
const { r, g, b, a } = this.fillColor;
const fill = `rgba(${r}, ${g}, ${b}, ${a})`;
const rect = this.clips.at(-1);
const clipPath = rect ? `clip-path="url(#${rect.id}) "` : ' ';
this.main += `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}" ${clipPath}/>`;
}
pushClip(x, y, width, height) {
const id = createId();
this.clips.push({ id, x, y, width, height });
{
const { x, y, width, height } = intersectRects(this.clips);
const shape = `<rect x="${x}" y="${y}" width="${width}" height="${height}" />`;
this.defs += `<clipPath id="${id}">${shape}</clipPath>`;
}
}
popClip() {
this.clips.pop();
}
image(x, y, w, h, image) {
this.main += `<image x="${x}" y="${y}" width="${w}" height="${h}" href="${image.src}" />`;
}
body() {
return `
<defs>
${this.defs}
</defs>
${this.main}
`;
}
}