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
5 changes: 5 additions & 0 deletions .changeset/eigenpal-image-border-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stll/folio-core": patch
---

Port eigenpal/docx-editor#1096 image border rendering through layout painting.
10 changes: 8 additions & 2 deletions api-reports/core/layout-engine.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,10 @@ export type ImageBlock = {
cropTop?: number;
cropRight?: number;
cropBottom?: number;
cropLeft?: number;
cropLeft?: number; /** CSS border width in pixels. eigenpal #1096. */
borderWidth?: number; /** CSS border color. eigenpal #1096. */
borderColor?: string; /** CSS border style. eigenpal #1096. */
borderStyle?: string;
pmStart?: number;
pmEnd?: number;
};
Expand Down Expand Up @@ -350,7 +353,10 @@ export type ImageRun = {
cropTop?: number;
cropRight?: number;
cropBottom?: number;
cropLeft?: number; /** Whether this picture is itself a tracked insertion (`<w:ins>`). eigenpal #641. */
cropLeft?: number; /** CSS border width in pixels. eigenpal #1096. */
borderWidth?: number; /** CSS border color. eigenpal #1096. */
borderColor?: string; /** CSS border style. eigenpal #1096. */
borderStyle?: string; /** Whether this picture is itself a tracked insertion (`<w:ins>`). eigenpal #641. */
isInsertion?: boolean; /** Whether this picture is itself a tracked deletion (`<w:del>`). eigenpal #641. */
isDeletion?: boolean; /** Author of the tracked change wrapping the picture. eigenpal #641. */
changeAuthor?: string; /** Date of the tracked change wrapping the picture. eigenpal #641. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";

import type { ImageRun } from "../../layout-engine/types";
import { schema } from "../../prosemirror/schema";
import { toFlowBlocks } from "./toFlowBlocks";

describe("image border conversion", () => {
test("carries image border attrs into inline layout runs", () => {
const image = schema.nodes.image.create({
src: "data:image/png;base64,",
width: 100,
height: 80,
borderWidth: 2,
borderColor: "currentColor",
borderStyle: "dashed",
});
const doc = schema.node("doc", null, [
schema.node("paragraph", null, [schema.text("Before "), image, schema.text(" after")]),
]);

const paragraph = toFlowBlocks(doc).find((block) => block.kind === "paragraph");
expect(paragraph?.kind).toBe("paragraph");
if (!paragraph || paragraph.kind !== "paragraph") {
return;
}

const imageRun = paragraph.runs.find((run): run is ImageRun => run.kind === "image");
expect(imageRun).toMatchObject({
borderWidth: 2,
borderColor: "currentColor",
borderStyle: "dashed",
});
});
});
22 changes: 22 additions & 0 deletions packages/core/src/layout-bridge/convert/toFlowBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,17 @@ function buildImageRun(
if (attrs.cropLeft != null) {
run.cropLeft = attrs.cropLeft;
}
// eigenpal #1096: image borders are authored on the PM image attrs and
// painted by layout-painter. PM defaults are null; treat null as absent.
if (attrs.borderWidth != null) {
run.borderWidth = attrs.borderWidth;
}
if (attrs.borderColor) {
run.borderColor = attrs.borderColor;
}
if (attrs.borderStyle) {
run.borderStyle = attrs.borderStyle;
}
if (attrs.position !== undefined) {
run.position = attrs.position;
}
Expand Down Expand Up @@ -2625,6 +2636,17 @@ function convertImage(node: PMNode, startPos: number, pageContentHeight?: number
if (attrs.cropLeft != null) {
imgBlock.cropLeft = attrs.cropLeft;
}
// eigenpal #1096: preserve image border attrs for floating/block image
// painting. PM defaults are null; treat null as absent.
if (attrs.borderWidth != null) {
imgBlock.borderWidth = attrs.borderWidth;
}
if (attrs.borderColor) {
imgBlock.borderColor = attrs.borderColor;
}
if (attrs.borderStyle) {
imgBlock.borderStyle = attrs.borderStyle;
}
return imgBlock;
}

Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/layout-engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ export type ImageRun = {
cropRight?: number;
cropBottom?: number;
cropLeft?: number;
/** CSS border width in pixels. eigenpal #1096. */
borderWidth?: number;
/** CSS border color. eigenpal #1096. */
borderColor?: string;
/** CSS border style. eigenpal #1096. */
borderStyle?: string;
/** Whether this picture is itself a tracked insertion (`<w:ins>`). eigenpal #641. */
isInsertion?: boolean;
/** Whether this picture is itself a tracked deletion (`<w:del>`). eigenpal #641. */
Expand Down Expand Up @@ -698,6 +704,12 @@ export type ImageBlock = {
cropRight?: number;
cropBottom?: number;
cropLeft?: number;
/** CSS border width in pixels. eigenpal #1096. */
borderWidth?: number;
/** CSS border color. eigenpal #1096. */
borderColor?: string;
/** CSS border style. eigenpal #1096. */
borderStyle?: string;
pmStart?: number;
pmEnd?: number;
};
Expand Down
121 changes: 120 additions & 1 deletion packages/core/src/layout-painter/renderImage-opacity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import type {
MeasuredLine,
ParagraphBlock,
} from "../layout-engine/types";
import { applyImageVisualAttrs, hasImageVisualAttrs, renderImageFragment } from "./renderImage";
import {
applyImageBorder,
applyImageVisualAttrs,
hasImageVisualAttrs,
renderImageFragment,
} from "./renderImage";
import { renderLine } from "./renderParagraph";

// Render-pipeline follow-up to PR #513 (eigenpal #424): the model layer now
Expand Down Expand Up @@ -149,6 +154,45 @@ describe("renderImageFragment opacity (floating block path)", () => {
});
});

describe("renderImageFragment image borders (floating block path)", () => {
test("emits CSS border and box sizing for a floating image with border attrs", () => {
const block = baseImageBlock({
borderWidth: 3,
borderColor: "currentColor",
borderStyle: "dashed",
});
const fragment = baseImageFragment({ isAnchored: true });

const containerEl = renderImageFragment(fragment, block, baseImageMeasure, fakeContext, {
document: fakeDocument,
}) as unknown as FakeElement;

const imgEl = findImageDescendant(containerEl);
expect(imgEl?.style["border"]).toBe("3px dashed currentColor");
expect(imgEl?.style["boxSizing"]).toBe("border-box");
});

test("paints the border on the overflow container when the image is cropped", () => {
const block = baseImageBlock({
borderWidth: 2,
borderColor: "#112233",
borderStyle: "solid",
cropLeft: 0.1,
cropRight: 0.1,
});
const fragment = baseImageFragment({ isAnchored: true });

const containerEl = renderImageFragment(fragment, block, baseImageMeasure, fakeContext, {
document: fakeDocument,
}) as unknown as FakeElement;

expect(containerEl.style["border"]).toBe("2px solid #112233");
expect(containerEl.style["boxSizing"]).toBe("border-box");
const imgEl = findImageDescendant(containerEl);
expect(imgEl?.style["border"]).toBeUndefined();
});
});

describe("renderLine inline image opacity", () => {
test("emits CSS opacity on the inline <img> when run carries opacity 0.5", () => {
const imageRun: ImageRun = {
Expand Down Expand Up @@ -222,6 +266,45 @@ describe("renderLine inline image opacity", () => {
});
});

describe("renderLine inline image borders", () => {
test("emits CSS border and box sizing on the inline <img>", () => {
const imageRun: ImageRun = {
kind: "image",
src: "data:image/png;base64,",
width: 100,
height: 80,
borderWidth: 2,
borderColor: "var(--image-border)",
borderStyle: "dotted",
pmStart: 1,
pmEnd: 2,
};
const block: ParagraphBlock = {
kind: "paragraph",
id: "p1",
runs: [imageRun],
pmStart: 0,
pmEnd: 3,
};
const line: MeasuredLine = {
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 1,
width: 100,
ascent: 20,
descent: 3,
lineHeight: 23,
};

const lineEl = renderLine(block, line, undefined, fakeDocument) as unknown as FakeElement;
const imgEl = findImageDescendant(lineEl);

expect(imgEl?.style["border"]).toBe("2px dotted var(--image-border)");
expect(imgEl?.style["boxSizing"]).toBe("border-box");
});
});

describe("ImageVisualAttrs helpers", () => {
test("hasImageVisualAttrs returns true for opacity < 1", () => {
expect(hasImageVisualAttrs({ opacity: 0.5 })).toBe(true);
Expand Down Expand Up @@ -264,3 +347,39 @@ describe("ImageVisualAttrs helpers", () => {
expect((img as unknown as FakeElement).style["marginTop"]).toBeUndefined();
});
});

describe("ImageBorderAttrs helpers", () => {
test("defaults missing border style and color while applying a positive width", () => {
const img = fakeDocument.createElement("img") as unknown as HTMLImageElement;

applyImageBorder(img, { borderWidth: 1 });

expect((img as unknown as FakeElement).style["border"]).toBe("1px solid #000000");
expect((img as unknown as FakeElement).style["boxSizing"]).toBe("border-box");
});

test("skips null PM defaults and zero border widths", () => {
const nullDefaultImg = fakeDocument.createElement("img") as unknown as HTMLImageElement;
const nullDefaultAttrs = { borderWidth: null } as unknown as { borderWidth?: number };
applyImageBorder(nullDefaultImg, nullDefaultAttrs);

const zeroWidthImg = fakeDocument.createElement("img") as unknown as HTMLImageElement;
applyImageBorder(zeroWidthImg, { borderWidth: 0, borderColor: "currentColor" });

expect((nullDefaultImg as unknown as FakeElement).style["border"]).toBeUndefined();
expect((zeroWidthImg as unknown as FakeElement).style["border"]).toBeUndefined();
});

test("applies borders to non-img containers for cropped frames", () => {
const container = fakeDocument.createElement("div") as unknown as HTMLElement;

applyImageBorder(container, {
borderWidth: 2,
borderStyle: "dashed",
borderColor: "#336699",
});

expect((container as unknown as FakeElement).style["border"]).toBe("2px dashed #336699");
expect((container as unknown as FakeElement).style["boxSizing"]).toBe("border-box");
});
});
30 changes: 30 additions & 0 deletions packages/core/src/layout-painter/renderImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ export type ImageVisualAttrs = {
cropLeft?: number;
};

export type ImageBorderAttrs = {
borderWidth?: number;
borderColor?: string;
borderStyle?: string;
};

/**
* Paint image borders carried through the layout model. Folio's PM schema
* uses `borderStyle` (not upstream's `borderKind`); the painter mirrors the
* editor DOM serialization while keeping the authored image box size stable.
*/
export function applyImageBorder(element: HTMLElement, border: ImageBorderAttrs): void {
if (border.borderWidth == null || border.borderWidth <= 0) {
return;
}
const borderStyle = border.borderStyle || "solid";
const borderColor = border.borderColor || "#000000";
element.style.border = `${border.borderWidth}px ${borderStyle} ${borderColor}`;
element.style.boxSizing = "border-box";
}

/**
* True when any visual attribute is set. Cheap call-site guard so the no-op
* common case skips the helper call.
Expand Down Expand Up @@ -268,5 +289,14 @@ export function renderImageFragment(
containerEl.append(imgEl);
}

// Cropped images clip an overflow-hidden container around a scaled `<img>`,
// so a border on the `<img>` itself is invisible. Paint on the container
// instead; uncropped images keep the border on the `<img>`.
if (hasImageCrop(block)) {
applyImageBorder(containerEl, block);
} else {
applyImageBorder(imgEl, block);
}

return containerEl;
}
24 changes: 23 additions & 1 deletion packages/core/src/layout-painter/renderParagraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ import {
import { hasCjk, segmentByScript } from "../utils/scriptSegments";
import { borderStrokeToCss, resolveParagraphBorderHorizontalOutsets } from "./borderStroke";
import { getAutomaticTextColorForBackground } from "./documentColors";
import { applyImageVisualAttrs, hasImageVisualAttrs, wrapImageWithCrop } from "./renderImage";
import {
applyImageBorder,
applyImageVisualAttrs,
hasImageCrop,
hasImageVisualAttrs,
wrapImageWithCrop,
} from "./renderImage";
Comment thread
cursor[bot] marked this conversation as resolved.
import { isFloatingImageRun, resolveImageLineAlign } from "./renderUtils";
import type { RenderContext } from "./renderUtils";
import { applySdtDataAttrs } from "./sdtBoundary";
Expand Down Expand Up @@ -741,6 +747,11 @@ function renderInlineImageRun(run: ImageRun, doc: Document): HTMLElement {
// happens to match, but be explicit so future transforms can't drift.
img.style.transformOrigin = "center center";
}
// Cropped images clip via an overflow-hidden wrapper; paint the border on
// that wrapper instead of the scaled `<img>` (which would be invisible).
if (!hasImageCrop(run)) {
applyImageBorder(img, run);
}

// Rotated images extend past `run.width × run.height`, so without a bbox
// wrapper the inline line box reserves too little space and the rotated
Expand Down Expand Up @@ -793,6 +804,9 @@ function renderInlineImageRun(run: ImageRun, doc: Document): HTMLElement {
if (run.distBottom) {
wrapper.style.marginBottom = `${run.distBottom}px`;
}
if (hasImageCrop(run)) {
applyImageBorder(wrapper, run);
}
applyPmPositions(wrapper, run.pmStart, run.pmEnd);
return wrapper;
}
Expand Down Expand Up @@ -859,6 +873,11 @@ function renderBlockImage(run: ImageRun, doc: Document): HTMLElement {
// future stacked transforms can't drift. eigenpal #424.
img.style.transformOrigin = "center center";
}
// Cropped images clip via an overflow-hidden wrapper; paint the border on
// that wrapper instead of the scaled `<img>` (which would be invisible).
if (!hasImageCrop(run)) {
applyImageBorder(img, run);
}

// Reserve the rotated bbox on the container so a rotated block image
// doesn't bleed into the next paragraph. The container is sized to the
Expand Down Expand Up @@ -902,6 +921,9 @@ function renderBlockImage(run: ImageRun, doc: Document): HTMLElement {
// Tailwind preflight sets img { display: block }, which would defeat
// text-align centring on the container. The inline-block wrapper
// restores centring via the container's text-align: center.
if (hasImageCrop(run)) {
applyImageBorder(wrapper, run);
}
applyPmPositions(container, run.pmStart, run.pmEnd);
container.append(wrapper);
return container;
Expand Down
Loading
Loading