import { describe, expect, it } from "@suaveplan/testing/runner"; import { PDF_CODES, PdfError } from "../error-codes/error-codes.js"; import { ContentStreamEncoder } from "./content-stream.js"; describe("ContentStreamEncoder — graphics state", () => { it("save / restore graphics state balance", { timeout: 5000 }, () => { const out = new ContentStreamEncoder() .saveGraphicsState() .restoreGraphicsState() .encode(); expect(out).toBe("q\nQ"); }); it("restoreGraphicsState without matching q raises", { timeout: 5000, }, () => { expect(() => new ContentStreamEncoder().restoreGraphicsState() ).toThrowError(PdfError); }); it("encode rejects unmatched save", { timeout: 5000 }, () => { const enc = new ContentStreamEncoder().saveGraphicsState(); expect(() => enc.encode()).toThrowError(PdfError); }); it("concatMatrix and setLineWidth emit numeric ops", { timeout: 5000, }, () => { const out = new ContentStreamEncoder() .concatMatrix(1, 0, 0, 1, 100, 200) .setLineWidth(2.5) .encode(); expect(out).toContain("1 0 0 1 100 200 cm"); expect(out).toContain("2.5000 w"); }); }); describe("ContentStreamEncoder — paths", () => { it("rectangle + fill emits expected ops", { timeout: 5000 }, () => { const out = new ContentStreamEncoder() .setFillColor({ r: 1, g: 0, b: 0 }) .rectangle(10, 10, 100, 50) .fill() .encode(); expect(out).toContain("1 0 0 rg"); expect(out).toContain("10 10 100 50 re"); expect(out).toContain("f"); }); it("moveTo / lineTo / closePath / stroke chain", { timeout: 5000 }, () => { const out = new ContentStreamEncoder() .moveTo(0, 0) .lineTo(10, 0) .closePath() .stroke() .encode(); expect(out).toContain("0 0 m"); expect(out).toContain("10 0 l"); expect(out).toContain("h"); expect(out).toContain("S"); }); it("closeAndStroke and fillAndStroke emit the right ops", { timeout: 5000, }, () => { const out = new ContentStreamEncoder() .rectangle(0, 0, 5, 5) .closeAndStroke() .rectangle(0, 0, 5, 5) .fillAndStroke() .encode(); expect(out).toContain("s"); expect(out).toContain("B"); }); }); describe("ContentStreamEncoder — colour", () => { it("setStrokeColor emits RG", { timeout: 5000 }, () => { const out = new ContentStreamEncoder() .setStrokeColor({ r: 0, g: 0.5, b: 1 }) .encode(); expect(out).toContain("0 0.5000 1 RG"); }); it("out-of-range channel raises INVALID_RGB", { timeout: 5000 }, () => { try { new ContentStreamEncoder().setFillColor({ r: 1.5, g: 0, b: 0, }); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(PdfError); expect((err as PdfError).code).toBe(PDF_CODES.INVALID_RGB); } }); it("NaN channel raises INVALID_RGB on stroke", { timeout: 5000 }, () => { try { new ContentStreamEncoder().setStrokeColor({ r: Number.NaN, g: 0, b: 0, }); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(PdfError); expect((err as PdfError).code).toBe(PDF_CODES.INVALID_RGB); } }); }); describe("ContentStreamEncoder — text", () => { it("text-showing operators round-trip", { timeout: 5000 }, () => { const out = new ContentStreamEncoder() .beginText() .setFont("F1", 12) .moveTextCursor(100, 700) .showText("Hello") .endText() .encode(); expect(out).toContain("BT"); expect(out).toContain("/F1 12 Tf"); expect(out).toContain("100 700 Td"); expect(out).toContain("(Hello) Tj"); expect(out).toContain("ET"); }); it("showText outside text mode raises", { timeout: 5000 }, () => { try { new ContentStreamEncoder().showText("oops"); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(PdfError); expect((err as PdfError).code).toBe(PDF_CODES.RENDER_FAILED); } }); it("beginText twice without endText raises", { timeout: 5000 }, () => { const enc = new ContentStreamEncoder().beginText(); expect(() => enc.beginText()).toThrowError(PdfError); }); it("endText outside text mode raises", { timeout: 5000 }, () => { expect(() => new ContentStreamEncoder().endText()).toThrowError( PdfError ); }); it("encode with unclosed text object raises", { timeout: 5000 }, () => { const enc = new ContentStreamEncoder().beginText().setFont("F1", 12); expect(() => enc.encode()).toThrowError(PdfError); }); it("setTextMatrix / setLeading / nextLine emit the right operators", { timeout: 5000, }, () => { const out = new ContentStreamEncoder() .beginText() .setFont("/F2", 10) .setTextMatrix(1, 0, 0, 1, 50, 50) .setLeading(14) .showText("line1") .nextLine() .showText("line2") .endText() .encode(); expect(out).toContain("1 0 0 1 50 50 Tm"); expect(out).toContain("14 TL"); expect(out).toContain("T*"); expect(out).toContain("(line1) Tj"); expect(out).toContain("(line2) Tj"); expect(out).toContain("/F2 10 Tf"); }); it("escapes parens + backslashes in showText", { timeout: 5000 }, () => { const out = new ContentStreamEncoder() .beginText() .setFont("F1", 12) .showText("(hi\\there)") .endText() .encode(); expect(out).toContain("(\\(hi\\\\there\\)) Tj"); }); }); describe("ContentStreamEncoder — XObjects", () => { it("drawImage emits /name Do", { timeout: 5000 }, () => { const out = new ContentStreamEncoder().drawImage("Im0").encode(); expect(out).toContain("/Im0 Do"); }); it("drawImage inside text object raises", { timeout: 5000 }, () => { const enc = new ContentStreamEncoder().beginText(); try { enc.drawImage("Im0"); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(PdfError); expect((err as PdfError).code).toBe(PDF_CODES.RENDER_FAILED); } }); it("drawImage accepts an already-prefixed name", { timeout: 5000 }, () => { const out = new ContentStreamEncoder().drawImage("/Im1").encode(); expect(out).toContain("/Im1 Do"); }); }); describe("ContentStreamEncoder — non-strict mode", () => { it("non-strict allows out-of-order ops", { timeout: 5000 }, () => { const enc = new ContentStreamEncoder({ strict: false }); // Sequence below would throw in strict mode. enc.restoreGraphicsState(); enc.showText("free-form"); enc.endText(); const out = enc.encode(); expect(out).toContain("Q"); expect(out).toContain("(free-form) Tj"); expect(out).toContain("ET"); }); }); describe("ContentStreamEncoder — appendRaw + length", () => { it("appendRaw injects a custom operator", { timeout: 5000 }, () => { const enc = new ContentStreamEncoder().appendRaw("0 0 0 rg"); expect(enc.length).toBe(1); expect(enc.encode()).toBe("0 0 0 rg"); }); });