Skip to content

Commit

Permalink
add SVG backend (paintToSvg, paintToSvgElements)
Browse files Browse the repository at this point in the history
Fixes #5
  • Loading branch information
chearon committed Apr 24, 2024
1 parent 35c7f94 commit c4183f1
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 0 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,22 @@ function paintToCanvas(root: BlockContainer, ctx: CanvasRenderingContext2D): voi

Paints the layout to a browser canvas, node-canvas, or similar standards-compliant context.

### `paintToSvg`

```ts
function paintToSvg(root: BlockContainer): string;
```

Paints the layout to an SVG string, with `@font-face` rules referencing the URL you passed to `registerFont`.

### `paintToSvgElements`

```ts
function paintToSvgElements(root: BlockContainer): string;
```

Similar to `paintToSvg`, but doesn't add `<svg>` or `@font-face` rules. Useful if you're painting inside of an already-existing SVG element.

### `paintToHtml`

```ts
Expand Down
29 changes: 29 additions & 0 deletions examples/svg-1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as flow from '../src/api-with-parse.js';
import fs from 'fs';

const doc = flow.parse(`
<html style="background-color: #eee; text-align: center;">
<div style="line-height: 1; color: white;">
<div style="display: inline-block;" x-dropflow-log>
<span style="float: left; padding: 3px; background-color: rgb(212, 35, 41);">n</span>
<span style="float: left; padding: 3px; background-color: black;">p</span>
<span style="float: left; padding: 3px; background-color: rgb(41, 124, 187);">r</span>
</div>
</div>
<p>
<strong>more from</strong>
<span style="display: inline-block; padding: 5px 10px; background-color: #7598c9; color: white;">news</span>
<span style="display: inline-block; padding: 5px 10px; background-color: #7598c9; color: white;">culture</span>
<span style="display: inline-block; padding: 5px 10px; background-color: #7598c9; color: white;">music</span>
</p>
</div>
`);

await flow.loadNotoFonts(doc, {paint: false});
const block = flow.generate(doc);
console.log(block.repr());
flow.layout(block, 600, 100);
const svg = flow.paintToSvg(block);

fs.writeFileSync(new URL('svg-1.svg', import.meta.url), svg);
36 changes: 36 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {DeclaredStyle, getRootStyle, initialStyle, computeElementStyle} from './
import {registerFont, unregisterFont, getFontUrls, RegisterFontOptions} from './text-font.js';
import {generateBlockContainer, layoutBlockBox, BlockFormattingContext, BlockContainer} from './layout-flow.js';
import HtmlPaintBackend from './paint-html.js';
import SvgPaintBackend from './paint-svg.js';
import CanvasPaintBackend, {Canvas, CanvasRenderingContext2D} from './paint-canvas.js';
import paintBlockRoot from './paint.js';
import {BoxArea} from './layout-box.js';
Expand Down Expand Up @@ -52,6 +53,41 @@ export function paintToHtml(root: BlockContainer): string {
return backend.s;
}

export function paintToSvg(root: BlockContainer): string {
const backend = new SvgPaintBackend();
const {width, height} = root.containingBlock;
let cssFonts = '';

paintBlockRoot(root, backend, true);

for (const [src, match] of backend.usedFonts) {
const {family, weight, style, stretch} = match.toCssDescriptor();
cssFonts +=
`@font-face {
font-family: "${family}";
font-weight: ${weight};
font-style: ${style};
font-stretch: ${stretch};
src: url("${src}") format("opentype");
}\n`;
}

return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">
<style type="text/css">
${cssFonts}
</style>
${backend.s}
</svg>
`.trim();
}

export function paintToSvgElements(root: BlockContainer): string {
const backend = new SvgPaintBackend();
paintBlockRoot(root, backend, true);
return backend.s;
}

export {eachRegisteredFont} from './text-font.js';

export function paintToCanvas(root: BlockContainer, ctx: CanvasRenderingContext2D): void {
Expand Down
74 changes: 74 additions & 0 deletions src/paint-svg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {ShapedItem} from './layout-text.js';
import {firstCascadeItem} from './text-font.js';

import type {Color} from './style.js';
import type {PaintBackend} from './paint.js';
import type {FaceMatch} from './text-font.js';

function encode(s: string) {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;');
}

function camelToKebab(camel: string) {
return camel.replace(/[A-Z]/g, s => '-' + s.toLowerCase());
}

export default class HtmlPaintBackend implements PaintBackend {
s: string;
fillColor: Color;
strokeColor: Color;
lineWidth: number;
direction: 'ltr' | 'rtl';
font: FaceMatch;
fontSize: number;
usedFonts: Map<string, FaceMatch>;

constructor() {
this.s = '';
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 = firstCascadeItem();
this.fontSize = 0;
this.usedFonts = new Map();
}

style(style: Record<string, string>) {
return Object.entries(style).map(([prop, value]) => {
return `${camelToKebab(prop)}: ${value}`;
}).join('; ');
}

edge(x: number, y: number, length: number, side: 'top' | 'right' | 'bottom' | 'left') {
const {r, g, b, a} = this.strokeColor;
const sw = this.lineWidth;
const width = side === 'top' || side === 'bottom' ? length + 'px' : sw + 'px';
const height = side === 'left' || side === 'right' ? length + 'px' : sw + 'px';
const backgroundColor = `rgba(${r}, ${g}, ${b}, ${a})`;

this.s += `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${backgroundColor}" />`;
}

text(x: number, y: number, item: ShapedItem, textStart: number, textEnd: number) {
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'
});

this.s += `<text x="${x}" y="${y}" style="${encode(style)}" fill="${color}">${encode(text)}</text>`;
this.usedFonts.set(item.match.filename, item.match);
}

rect(x: number, y: number, w: number, h: number) {
const {r, g, b, a} = this.fillColor;
const fill = `rgba(${r}, ${g}, ${b}, ${a})`;

this.s += `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}" />`;
}
}

0 comments on commit c4183f1

Please sign in to comment.