diff --git a/src/index.ts b/src/index.ts index fb9f46b..4a8d6d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export type { SlideElement, BaseElement, TextElement, + TextShapeFrame, TextRun, TextField, LineBreak, diff --git a/src/parser.ts b/src/parser.ts index f719e8b..d07c603 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -13,6 +13,7 @@ import type { TableCell, SlideBackground, GraphicFrameElement, + TextShapeFrame, DiagramData, DiagramNode, DiagramShape, @@ -420,7 +421,13 @@ function parseShape( const txBody = sp.querySelector("txBody"); if (txBody) { if (hasRenderableTextContent(txBody)) { - return parseTextElement(txBody, position, zIndex, themeColors); + return parseTextElement( + txBody, + position, + zIndex, + themeColors, + parseTextShapeFrame(sp, themeColors), + ); } if (isPlaceholderShape(sp)) { @@ -509,6 +516,29 @@ function hasExplicitShapeVisualStyle(sp: Element): boolean { return false; } +function parseTextShapeFrame( + sp: Element, + themeColors: Map, +): TextShapeFrame | undefined { + if (!hasExplicitShapeVisualStyle(sp)) { + return undefined; + } + + const prstGeom = sp.querySelector(":scope > spPr > prstGeom"); + if (!prstGeom) { + return undefined; + } + + return { + shapeType: prstGeom.getAttribute("prst") || "rect", + fillColor: parseFillColor(sp, themeColors), + strokeColor: parseStrokeColor(sp, themeColors), + strokeWidth: parseStrokeWidth(sp), + strokeDash: parseShapeStrokeDash(sp), + roundedCorners: parseRoundedCornersFromGeom(prstGeom), + }; +} + function isPlaceholderShape(sp: Element): boolean { return sp.querySelector(":scope > nvSpPr > nvPr > ph") !== null; } @@ -530,6 +560,7 @@ function parseTextElement( position: { x: number; y: number; width: number; height: number }, zIndex: number, themeColors: Map, + frame?: TextShapeFrame, ): TextElement { // Get default properties const defaultFontSize = parseFontSize(txBody); @@ -582,6 +613,7 @@ function parseTextElement( zIndex, runs, paragraphs: paragraphs.length > 1 ? paragraphs : undefined, + frame, defaultFontSize, defaultFontFamily, defaultColor, @@ -1570,6 +1602,16 @@ function parseStrokeWidth(sp: Element): number | undefined { return parseInt(w, 10) / 12700; } +function parseShapeStrokeDash(sp: Element): string | undefined { + const ln = sp.querySelector("spPr > ln"); + if (!ln || ln.querySelector(":scope > noFill")) return undefined; + + const dash = ln.querySelector(":scope > prstDash")?.getAttribute("val"); + if (dash === "sysDot" || dash === "dot") return "dotted"; + if (dash === "dash" || dash === "sysDash") return "dashed"; + return dash ? "solid" : undefined; +} + /** * Parse graphic frame element (charts, tables, SmartArt) */ diff --git a/src/renderer.ts b/src/renderer.ts index fb5ee0b..fa60198 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -2,6 +2,7 @@ import type { PptxSlide, SlideElement, TextElement, + TextShapeFrame, TextRun, TextField, ContentElement, @@ -127,6 +128,52 @@ function renderGroupElement(element: GroupElement, scale: number): HTMLElement { return div; } +function applyBoxFrameStyles( + element: HTMLElement, + frame: Pick, + width: number, + height: number, + scale: number, + options: { includeFill?: boolean } = {}, +): void { + if (options.includeFill !== false) { + element.style.backgroundColor = frame.fillColor || "transparent"; + } + + if (frame.strokeWidth && frame.strokeWidth > 0) { + element.style.borderWidth = `${frame.strokeWidth * scale}px`; + element.style.borderStyle = frame.strokeDash || "solid"; + element.style.borderColor = frame.strokeColor || "#000"; + } + + element.style.borderRadius = getBoxFrameRadius(frame, width, height, scale); +} + +function getBoxFrameRadius( + frame: Pick, + width: number, + height: number, + scale: number, +): string { + if (frame.roundedCorners !== undefined) { + const radius = + frame.roundedCorners <= 1 + ? Math.min(width, height) * frame.roundedCorners * scale + : frame.roundedCorners * scale; + return `${radius}px`; + } + + switch (frame.shapeType) { + case "ellipse": + case "circle": + return "50%"; + case "roundRect": + return `${Math.min(width, height) * 0.1 * scale}px`; + default: + return "0"; + } +} + /** * Render text element with rich formatting */ @@ -144,6 +191,10 @@ function renderTextElement(element: TextElement, scale: number): HTMLElement { // Always use box-sizing for consistent sizing div.style.boxSizing = "border-box"; + if (element.frame) { + applyBoxFrameStyles(div, element.frame, element.width, element.height, scale); + } + // Container styling div.style.fontFamily = element.defaultFontFamily || "Arial, sans-serif"; div.style.textAlign = element.align || "left"; @@ -575,29 +626,7 @@ function renderShapeElement(element: ShapeElement, scale: number): HTMLElement { div.style.backgroundColor = element.fillColor || "transparent"; } - // Border styling - if (element.strokeWidth && element.strokeWidth > 0) { - div.style.borderWidth = `${element.strokeWidth * scale}px`; - div.style.borderStyle = element.strokeDash || "solid"; - div.style.borderColor = element.strokeColor || "#000"; - } - - // Border radius based on shape type - if (element.roundedCorners) { - div.style.borderRadius = `${element.roundedCorners * scale}px`; - } else { - switch (element.shapeType) { - case "ellipse": - case "circle": - div.style.borderRadius = "50%"; - break; - case "roundRect": - div.style.borderRadius = `${Math.min(element.width, element.height) * 0.1 * scale}px`; - break; - default: - div.style.borderRadius = "0"; - } - } + applyBoxFrameStyles(div, element, element.width, element.height, scale, { includeFill: false }); return div; } diff --git a/src/types.ts b/src/types.ts index 31322b1..363f2fc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,6 +73,7 @@ export interface TextElement extends BaseElement { type: "text"; runs: TextRun[]; // Array of text runs with individual formatting paragraphs?: Paragraph[]; // Support for multiple paragraphs + frame?: TextShapeFrame; // Visual frame styling for text-bearing shapes defaultFontSize?: number; defaultFontFamily?: string; defaultColor?: string; @@ -91,6 +92,15 @@ export interface TextElement extends BaseElement { bottomInset?: number; // bottom margin inset in points } +export interface TextShapeFrame { + shapeType: string; + fillColor?: string; + strokeColor?: string; + strokeWidth?: number; + strokeDash?: string; + roundedCorners?: number; +} + export interface Paragraph { runs: ContentElement[]; align?: "left" | "center" | "right" | "justify"; diff --git a/test/index.test.ts b/test/index.test.ts index 2a6f308..1201a1c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -232,6 +232,106 @@ describe("pptx-react-renderer", () => { expect(slides[0].elements[0]).toMatchObject({ type: "text" }); }); + it("parses renderable text shapes with visual frame styling", async () => { + const buffer = await createTestPptx([ + { + rawXml: ` + + + + + + + + + + + + + + + + + + + Styled text box + + + + + +`, + }, + ]); + + const { slides } = await parsePptx(buffer); + expect(slides[0].elements[0]).toMatchObject({ + type: "text", + x: 96, + y: 96, + width: 192, + height: 96, + frame: { + shapeType: "roundRect", + fillColor: "#4472c4", + strokeColor: "#112233", + strokeWidth: 2, + strokeDash: "dashed", + roundedCorners: 0.15, + }, + }); + }); + + it("renders text shape frame styling without dropping text content", async () => { + const buffer = await createTestPptx([ + { + rawXml: ` + + + + + + + + + + + + + + + + + + + Styled text box + + + + + +`, + }, + ]); + + const { slides } = await parsePptx(buffer); + const container = document.createElement("div"); + + renderSlides(slides, { container }); + + const textElement = container.querySelector(".pptx-text-element"); + if (!(textElement instanceof HTMLElement)) { + throw new Error("Expected a rendered text element"); + } + + expect(textElement.textContent).toContain("Styled text box"); + expect(textElement.style.backgroundColor).toBe("rgb(68, 114, 196)"); + expect(textElement.style.borderWidth).toBe("2px"); + expect(textElement.style.borderStyle).toBe("dashed"); + expect(textElement.style.borderColor).toBe("rgb(17, 34, 51)"); + expect(parseFloat(textElement.style.borderRadius)).toBeCloseTo(14.4, 4); + }); + it("falls back to a placeholder for unresolved images", async () => { const buffer = await createTestPptx([ {