Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type {
SlideElement,
BaseElement,
TextElement,
TextShapeFrame,
TextRun,
TextField,
LineBreak,
Expand Down
44 changes: 43 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
TableCell,
SlideBackground,
GraphicFrameElement,
TextShapeFrame,
DiagramData,
DiagramNode,
DiagramShape,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -509,6 +516,29 @@ function hasExplicitShapeVisualStyle(sp: Element): boolean {
return false;
}

function parseTextShapeFrame(
sp: Element,
themeColors: Map<string, string>,
): 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;
}
Expand All @@ -530,6 +560,7 @@ function parseTextElement(
position: { x: number; y: number; width: number; height: number },
zIndex: number,
themeColors: Map<string, string>,
frame?: TextShapeFrame,
): TextElement {
// Get default properties
const defaultFontSize = parseFontSize(txBody);
Expand Down Expand Up @@ -582,6 +613,7 @@ function parseTextElement(
zIndex,
runs,
paragraphs: paragraphs.length > 1 ? paragraphs : undefined,
frame,
defaultFontSize,
defaultFontFamily,
defaultColor,
Expand Down Expand Up @@ -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)
*/
Expand Down
75 changes: 52 additions & 23 deletions src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
PptxSlide,
SlideElement,
TextElement,
TextShapeFrame,
TextRun,
TextField,
ContentElement,
Expand Down Expand Up @@ -127,6 +128,52 @@ function renderGroupElement(element: GroupElement, scale: number): HTMLElement {
return div;
}

function applyBoxFrameStyles(
element: HTMLElement,
frame: Pick<TextShapeFrame, "shapeType" | "fillColor" | "strokeColor" | "strokeWidth" | "strokeDash" | "roundedCorners">,
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";
}
Comment on lines +143 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip fallback border color for noFill text frames

When a text-bearing shape has an explicit line with <noFill/>, parseTextShapeFrame can still pass a positive strokeWidth but no strokeColor. This branch then renders a visible border using #000, so “no outline” shapes become black-outlined after this change. The issue reproduces on any text shape whose OOXML includes <a:ln w="..."><a:noFill/></a:ln>, and it is introduced by the new frame rendering path for text elements.

Useful? React with 👍 / 👎.


element.style.borderRadius = getBoxFrameRadius(frame, width, height, scale);
}

function getBoxFrameRadius(
frame: Pick<TextShapeFrame, "shapeType" | "roundedCorners">,
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
*/
Expand All @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand Down
100 changes: 100 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<?xml version="1.0" encoding="UTF-8"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:spPr>
<a:xfrm><a:off x="914400" y="914400"/><a:ext cx="1828800" cy="914400"/></a:xfrm>
<a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom>
<a:solidFill><a:srgbClr val="4472C4"/></a:solidFill>
<a:ln w="25400">
<a:solidFill><a:srgbClr val="112233"/></a:solidFill>
<a:prstDash val="dash"/>
</a:ln>
</p:spPr>
<p:txBody>
<a:bodyPr anchor="ctr" lIns="38100" rIns="38100"/>
<a:lstStyle/>
<a:p>
<a:pPr algn="ctr"><a:buNone/></a:pPr>
<a:r><a:rPr sz="1800"/><a:t>Styled text box</a:t></a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>`,
},
]);

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: `<?xml version="1.0" encoding="UTF-8"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:spPr>
<a:xfrm><a:off x="914400" y="914400"/><a:ext cx="1828800" cy="914400"/></a:xfrm>
<a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom>
<a:solidFill><a:srgbClr val="4472C4"/></a:solidFill>
<a:ln w="25400">
<a:solidFill><a:srgbClr val="112233"/></a:solidFill>
<a:prstDash val="dash"/>
</a:ln>
</p:spPr>
<p:txBody>
<a:bodyPr anchor="ctr" lIns="38100" rIns="38100"/>
<a:lstStyle/>
<a:p>
<a:pPr algn="ctr"><a:buNone/></a:pPr>
<a:r><a:rPr sz="1800"/><a:t>Styled text box</a:t></a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>`,
},
]);

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([
{
Expand Down
Loading