diff --git a/lib/drawGraphicsToCanvas.ts b/lib/drawGraphicsToCanvas.ts index 997f8bb..8c2bd45 100644 --- a/lib/drawGraphicsToCanvas.ts +++ b/lib/drawGraphicsToCanvas.ts @@ -498,7 +498,17 @@ export function drawGraphicsToCanvas( ctx.textAlign = alignMap[anchor] ctx.textBaseline = baselineMap[anchor] - ctx.fillText(text.text, projected.x, projected.y) + if (text.rotation) { + // Text.rotation is counter-clockwise in graphics (y-up) space; canvas + // rotate() is clockwise in screen (y-down) space, so negate. + ctx.save() + ctx.translate(projected.x, projected.y) + ctx.rotate((-text.rotation * Math.PI) / 180) + ctx.fillText(text.text, 0, 0) + ctx.restore() + } else { + ctx.fillText(text.text, projected.x, projected.y) + } }) } diff --git a/lib/getPngBufferFromGraphicsObject.ts b/lib/getPngBufferFromGraphicsObject.ts index 9997e8b..724ba1a 100644 --- a/lib/getPngBufferFromGraphicsObject.ts +++ b/lib/getPngBufferFromGraphicsObject.ts @@ -504,6 +504,11 @@ export async function getPngBufferFromGraphicsObject( fontSize: (text.fontSize ?? 12) * strokeScale, color: text.color || "black", anchorAlignment: text.anchorSide ?? "center", + // Text.rotation is counter-clockwise in graphics (y-up) space; the + // raster context rotates clockwise in screen (y-down) space, so negate. + rotationRadians: text.rotation + ? (-text.rotation * Math.PI) / 180 + : undefined, }) } diff --git a/lib/getSvgFromGraphicsObject.ts b/lib/getSvgFromGraphicsObject.ts index d625306..20aed0f 100644 --- a/lib/getSvgFromGraphicsObject.ts +++ b/lib/getSvgFromGraphicsObject.ts @@ -68,10 +68,26 @@ function getBounds(graphics: GraphicsObject): Bounds { const { dx, dy } = offsetMap[anchor] const x0 = t.x + dx const y0 = t.y + dy + if (!t.rotation) { + return [ + { x: x0, y: y0 }, + { x: x0 + width, y: y0 + height }, + ] + } + // Rotate the text box's corners about the anchor so rotated text still + // contributes its true extents to the bounds. + const rad = (t.rotation * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) return [ { x: x0, y: y0 }, + { x: x0 + width, y: y0 }, + { x: x0, y: y0 + height }, { x: x0 + width, y: y0 + height }, - ] + ].map((corner) => ({ + x: t.x + (corner.x - t.x) * cos - (corner.y - t.y) * sin, + y: t.y + (corner.x - t.x) * sin + (corner.y - t.y) * cos, + })) }), ] @@ -616,6 +632,13 @@ export function getSvgFromGraphicsObject( "font-family": "sans-serif", "text-anchor": alignMap[anchor], "dominant-baseline": baselineMap[anchor], + // Text.rotation is counter-clockwise in graphics (y-up) space; + // SVG rotate() is clockwise in screen (y-down) space, so negate. + ...(txt.rotation + ? { + transform: `rotate(${-txt.rotation}, ${projected.x}, ${projected.y})`, + } + : {}), }, children: [{ type: "text", value: txt.text }], } diff --git a/lib/types.ts b/lib/types.ts index de2d493..6ece1e2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -91,6 +91,11 @@ export interface Text { anchorSide?: NinePointAnchor color?: string fontSize?: number + /** + * Rotation in degrees, counter-clockwise about the anchor point in graphics + * (y-up) space. 90 makes the text read bottom-to-top. + */ + rotation?: number layer?: string step?: number } diff --git a/tests/__snapshots__/getSvgFromGraphicsObject-rotated-texts.snap.svg b/tests/__snapshots__/getSvgFromGraphicsObject-rotated-texts.snap.svg new file mode 100644 index 0000000..61499bf --- /dev/null +++ b/tests/__snapshots__/getSvgFromGraphicsObject-rotated-texts.snap.svg @@ -0,0 +1,44 @@ +SPI_SCKFLAT \ No newline at end of file diff --git a/tests/getSvgFromGraphicsObject.test.ts b/tests/getSvgFromGraphicsObject.test.ts index 3458ea5..4753f1d 100644 --- a/tests/getSvgFromGraphicsObject.test.ts +++ b/tests/getSvgFromGraphicsObject.test.ts @@ -347,6 +347,33 @@ describe("getSvgFromGraphicsObject", () => { expect(svg).toMatchSvgSnapshot(import.meta.path, "texts") }) + test("rotates text about its anchor", () => { + const input: GraphicsObject = { + texts: [ + { + x: 0, + y: 0, + text: "SPI_SCK", + rotation: 90, + }, + { + x: 2, + y: 0, + text: "FLAT", + }, + ], + } + + const svg = getSvgFromGraphicsObject(input) + // rotation is CCW in graphics space -> negated for SVG's screen space + expect(svg).toMatch(/transform="rotate\(-90, [\d.]+, [\d.]+\)"/) + // unrotated text carries no transform + const flatText = svg.split(" chunk.includes("FLAT")) + expect(flatText).toBeDefined() + expect(flatText!).not.toContain("transform=") + expect(svg).toMatchSvgSnapshot(import.meta.path, "rotated-texts") + }) + test("respects text anchorSide", () => { const input: GraphicsObject = { texts: [