diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f9e4193..fed203c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -55,14 +55,15 @@ jobs:
run: pnpm --filter @unlayer/react-elements typecheck
# ── Bundle size budget ──────────────────────────────────────
- # Fail if the react-elements ESM bundle exceeds 60KB.
- # Current size: ~49KB (14+ components). Budget gives room for
- # growth but catches accidental dependency bundling.
+ # Fail if the react-elements ESM bundle exceeds 68KB.
+ # Current size: ~63KB (14+ components + image width-pinning geometry).
+ # The budget tracks the unminified ESM output as a proxy for code volume;
+ # it still flags accidental dependency bundling (any real dep is 10KB+).
- name: Check bundle size
run: |
BUNDLE="packages/react/dist/index.js"
SIZE=$(wc -c < "$BUNDLE" | tr -d ' ')
- MAX_SIZE=60000
+ MAX_SIZE=68000
echo "Bundle: $BUNDLE"
echo "Size: $SIZE bytes (budget: $MAX_SIZE bytes)"
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
diff --git a/packages/react/src/components/Image.test.tsx b/packages/react/src/components/Image.test.tsx
index dd2d277..eb2117d 100644
--- a/packages/react/src/components/Image.test.tsx
+++ b/packages/react/src/components/Image.test.tsx
@@ -143,10 +143,13 @@ describe("Image Component", () => {
expect(imageSrc().autoWidth).toBe(true);
});
- it("a flat px / number width is the natural size, still responsive", () => {
+ it("a flat px / number width pins to a percent of the content slot", () => {
+ // Display intent, not natural size: 300 / (500 default contentWidth − 20px
+ // container padding) = 62.5%, stored as autoWidth:false so it survives a
+ // Builder round-trip.
const src = imageSrc();
- expect(src.autoWidth).toBe(true);
- expect(src.width).toBe(300);
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("62.5%");
});
it("a percent maxWidth is a fixed display size (autoWidth:false)", () => {
diff --git a/packages/react/src/components/Image.tsx b/packages/react/src/components/Image.tsx
index 62e7aef..f2722d8 100644
--- a/packages/react/src/components/Image.tsx
+++ b/packages/react/src/components/Image.tsx
@@ -55,13 +55,17 @@ const Image = createItemComponent({
name: "Image",
defaultValues: DEFAULT_VALUES,
propMapper: (props) => {
- const { alt, src, ...rest } = props;
+ // `width`/`maxWidth` are DISPLAY intent — pull them out so mapSemanticProps
+ // can't fold them into the natural-size `src.width` field. They drive
+ // autoWidth + maxWidth below; the natural dimensions come only from an
+ // object `src` / the loaded image.
+ const { alt, src, width: widthProp, maxWidth: maxWidthProp, ...rest } = props;
// Normalize a string `values.src` to { url } before mapping. mapSemanticProps
- // merges flat src props (width=, …) onto the src group by spreading; if the
- // escape-hatch src is a string, spreading it character-spreads the URL into
- // numeric keys ({0:"h",1:"t",…}) and loses the url. Wrapping it first keeps
- // the merge object-to-object.
+ // merges flat src props onto the src group by spreading; if the escape-hatch
+ // src is a string, spreading it character-spreads the URL into numeric keys
+ // ({0:"h",1:"t",…}) and loses the url. Wrapping it first keeps the merge
+ // object-to-object.
const restValues = (rest as { values?: { src?: unknown } }).values;
const normalizedRest =
restValues && typeof restValues.src === "string"
@@ -81,11 +85,11 @@ const Image = createItemComponent({
// Build the src value. Note: ImageValues.src is typed as string (codegen
// bug) but the exporter expects { url, autoWidth?, maxWidth?, width?, ... }.
- // The user can provide src three ways — the `src` prop (string or object),
- // flat semantic props (width=, maxWidth=, …), and the `values.src` escape
- // hatch — the latter two land on `base.src` via mapSemanticProps. Combine
- // all user-provided src fields (defensively, since base.src may be a string
- // or non-object), then apply the width pin.
+ // Natural src fields come from the `src` prop (string or object) and the
+ // `values.src` escape hatch (which lands on `base.src` via mapSemanticProps);
+ // the display `width`/`maxWidth` props were pulled out above. Combine the
+ // natural fields (defensively, since base.src may be a string or non-object),
+ // then apply the display intent.
const baseSrc = (base as Record).src;
const fromValues: Record =
baseSrc && typeof baseSrc === "object" && !Array.isArray(baseSrc)
@@ -108,35 +112,55 @@ const Image = createItemComponent({
: { ...DEFAULT_VALUES.src };
const merged = { ...start, ...userSrc } as Record;
- // In Unlayer's value model, `src.width`/`height` are the NATURAL image
- // size and never set the display width. Display size = autoWidth + maxWidth:
- // the default (and "100%") is responsive (autoWidth:true, capped at the
- // natural size); a fixed display size is autoWidth:false + `maxWidth` as a
- // PERCENT of the container. An explicit autoWidth is honored.
+ // In Unlayer's value model, `src.width`/`height` are the NATURAL image size
+ // and never set the display size. Display size = autoWidth + maxWidth: the
+ // default (and "100%") is responsive (autoWidth:true); a fixed display size
+ // is autoWidth:false + `maxWidth` as a PERCENT of the column's content slot
+ // (see image-sizing.ts for why a percent, not the natural-size field). A
+ // px/number pin is kept here as a placeholder and converted to that percent
+ // by the width-aware pass in renderToHtml / renderToJson, where the column
+ // geometry is known. An explicit autoWidth is honored.
const pctRe = /^\d+(?:\.\d+)?%$/;
+ const asPercent = (v: unknown): string | undefined =>
+ typeof v === "string" && pctRe.test(v.trim()) ? v.trim() : undefined;
+ const asPx = (v: unknown): number | undefined => {
+ if (typeof v === "number" && Number.isFinite(v)) return v;
+ if (typeof v === "string") {
+ const t = v.trim();
+ if (pctRe.test(t)) return undefined;
+ const m = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(t);
+ if (m) return parseFloat(m[1]);
+ }
+ return undefined;
+ };
- // A `width` that is a percent is a DISPLAY width → route it to maxWidth.
- // A px / bare-number `width` is the NATURAL size (a number); the natural
- // cap then gives an "up to px, responsive" display for free.
- if (typeof merged.width === "string") {
- const t = merged.width.trim();
- if (pctRe.test(t)) {
- if (userSrc.maxWidth === undefined) merged.maxWidth = t;
- delete merged.width;
- } else {
- const px = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(t);
- if (px) merged.width = parseFloat(px[1]);
+ // Resolve display intent in priority order: flat `width` → flat `maxWidth`
+ // → escape-hatch values.src.maxWidth.
+ let displayPct: string | undefined;
+ let displayPx: number | undefined;
+ for (const candidate of [widthProp, maxWidthProp, userSrc.maxWidth]) {
+ if (candidate === undefined) continue;
+ const pct = asPercent(candidate);
+ if (pct) {
+ displayPct = pct;
+ break;
+ }
+ const px = asPx(candidate);
+ if (px != null) {
+ displayPx = px;
+ break;
}
}
- const displayPct =
- typeof merged.maxWidth === "string" && pctRe.test(merged.maxWidth.trim())
- ? merged.maxWidth.trim()
- : undefined;
+ // An explicit escape-hatch `autoWidth` is honored as-is (its own maxWidth
+ // stays); otherwise the display intent decides.
if (userSrc.autoWidth === undefined) {
if (displayPct && displayPct !== "100%") {
merged.autoWidth = false;
merged.maxWidth = displayPct;
+ } else if (displayPx != null) {
+ merged.autoWidth = false;
+ merged.maxWidth = displayPx;
} else {
merged.autoWidth = true;
merged.maxWidth = "100%";
diff --git a/packages/react/src/components/Image.width-roundtrip.test.tsx b/packages/react/src/components/Image.width-roundtrip.test.tsx
new file mode 100644
index 0000000..bec3f6c
--- /dev/null
+++ b/packages/react/src/components/Image.width-roundtrip.test.tsx
@@ -0,0 +1,282 @@
+import { describe, it, expect } from "vitest";
+import React from "react";
+import Email from "./Email";
+import Page from "./Page";
+import Image from "./Image";
+import Row from "./Row";
+import { Column } from "./Column";
+import { ColumnLayouts } from "../layouts/ColumnLayouts";
+import { renderToJson } from "../utils/render-to-json";
+import { renderToHtml } from "../utils/render-to-html";
+import { bodyContentWidthPx } from "../utils/image-sizing";
+
+// Regression guard for the design-JSON round-trip: a fixed image width must pin
+// as autoWidth:false + a PERCENT maxWidth (of the column's content slot) so an
+// editor keeps it instead of falling back to the image's natural dimensions. A
+// responsive image (no width) must stay autoWidth:true.
+
+/** Pull the first image's resolved src out of a renderToJson design. */
+function imgSrc(element: React.ReactElement): Record {
+ const json = renderToJson(element);
+ return json.body.rows[0].columns[0].contents[0].values.src as Record;
+}
+
+describe("Image fixed-width round-trip (renderToJson)", () => {
+ it("numeric width pins: autoWidth:false + percent of the column slot", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ // 600 content − 10px×2 default container padding = 580 slot → 300/580 ≈ 51.72%
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("51.72%");
+ });
+
+ it("px-string width pins identically to the numeric form", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("51.72%");
+ });
+
+ it("a bare numeric-string contentWidth is treated as px, matching the renderer", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ // "600" must size against 600 (not the 500 fallback) → same as "600px".
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("51.72%");
+ });
+
+ it("a percent contentWidth falls back to the responsive base width (not the % value)", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ // % isn't a fixed px slot → base 500 → 300/(500−20) = 62.5%.
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("62.5%");
+ });
+
+ it("percent width stays a percent (already canonical)", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("50%");
+ });
+
+ it("no width stays responsive (autoWidth:true) — no regression", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ expect(src.autoWidth).toBe(true);
+ expect(src.maxWidth).toBe("100%");
+ });
+
+ it("a pin wider than its column clamps to 100% (3-equal columns)", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ // 600/3 = 200 − 20 padding = 180 slot; 300 > 180 → clamps to 100%
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("100%");
+ });
+
+ it("the pin scales with column share (second of two columns)", () => {
+ const json = renderToJson(
+
+
+
+
+
+
+
+
+
+
+ );
+ const src = json.body.rows[0].columns[1].contents[0].values.src as Record;
+ // 600/2 = 300 − 20 = 280 slot → 120/280 ≈ 42.86%
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("42.86%");
+ });
+
+ it("leaves a non-px maxWidth on a pinned src untouched (no bogus percent)", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ // A non-px unit is not a px pin → the conversion pass must leave it alone,
+ // not parseFloat it into a tiny percent.
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("1.5em");
+ });
+
+ it("a px width does not pollute the natural src dimensions", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ expect(src.width).toBe(1200); // natural width untouched
+ expect(src.height).toBe(600);
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("51.72%");
+ });
+
+ it("an explicit percent maxWidth on the src escape hatch is preserved (not overwritten by natural width)", () => {
+ const src = imgSrc(
+
+
+
+
+
+
+
+ );
+ expect(src.width).toBe(1600);
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("50%");
+ });
+});
+
+describe("Image fixed-width rendering (renderToHtml)", () => {
+ it("renders a responsive percent width, never a hard px that overflows", () => {
+ const html = renderToHtml(
+
+
+
+
+
+
+
+ );
+ // The pin renders as `width: %` (caps at the slot via max-width), so the
+ // image shrinks with a narrow column instead of forcing a fixed 300px box.
+ expect(html).toMatch(/width:\s*51\.72%/);
+ expect(html).not.toMatch(/width:\s*300px/);
+ });
+
+ it("caps a too-wide pin at 100% in a narrow column", () => {
+ const html = renderToHtml(
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ expect(html).toMatch(/width:\s*100%/);
+ expect(html).not.toMatch(/width:\s*300px/);
+ });
+});
+
+describe("bodyContentWidthPx (one px-parse shared by Row grid CSS + image geometry)", () => {
+ it("number / px-string / bare-numeric-string resolve to px", () => {
+ expect(bodyContentWidthPx(600)).toBe(600);
+ expect(bodyContentWidthPx("600px")).toBe(600);
+ expect(bodyContentWidthPx("600")).toBe(600);
+ });
+
+ it("percent / auto / missing fall back (never a parseInt artifact like 50)", () => {
+ expect(bodyContentWidthPx("50%")).toBe(500);
+ expect(bodyContentWidthPx("auto")).toBe(500);
+ expect(bodyContentWidthPx(undefined)).toBe(500);
+ });
+
+ it("honors a custom fallback", () => {
+ expect(bodyContentWidthPx("50%", 600)).toBe(600);
+ });
+});
+
+describe("renderToJson row cells default to the Column count", () => {
+ it("a stray non-Column child is not counted as a cell (correct column-share math)", () => {
+ const json = renderToJson(
+
+
+
+
+
+
+
+
+ {/* invalid: a stray non-Column child (warned + skipped) */}
+
+
+
+ );
+ const row = json.body.rows[0];
+ expect(row.cells).toEqual([1, 1]); // 2 Columns, not 3 children
+ // First column sized against the 2-col slot (600/2 − 20 = 280): 120/280 =
+ // 42.86%, not 66.67% (which a 3-cell miscount would produce).
+ const src = row.columns[0].contents[0].values.src as Record;
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("42.86%");
+ });
+});
diff --git a/packages/react/src/components/Row.tsx b/packages/react/src/components/Row.tsx
index 55067bf..8343ac7 100644
--- a/packages/react/src/components/Row.tsx
+++ b/packages/react/src/components/Row.tsx
@@ -5,6 +5,7 @@ import type { ColumnLayout } from "@unlayer-internal/shared-elements";
import { RowExporters } from "@unlayer/exporters";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";
import { nextHtmlId } from "../utils/create-component";
+import { bodyContentWidthPx } from "../utils/image-sizing";
import type { SizeInput } from "../types";
import { ROW_DEFAULTS, BODY_DEFAULTS } from "../utils/container-defaults";
@@ -137,20 +138,17 @@ type ContainerExporterFunction = (innerHTML: string, values: Record
/**
* Resolve the row's content width (px number) from body values. contentWidth
- * may be "600px" (string) or 600 (number); the grid CSS needs a number.
- * In EMAIL mode this drives the per-column desktop widths and the stacking
- * breakpoint — so without it, multi-column emails were pinned to 600px
- * regardless of . Web mode uses percentages, so this is
- * a no-op there.
+ * may be "600px" (string), 600 (number), or "600" (bare numeric string); the
+ * grid CSS needs a number. In EMAIL mode this drives the per-column desktop
+ * widths and the stacking breakpoint — so without it, multi-column emails were
+ * pinned to 600px regardless of . Web mode uses percentages,
+ * so this is a no-op there.
+ *
+ * Uses the same strict px parse as the image slot geometry (a non-px value like
+ * "50%" → fallback, not a parseInt artifact like 50) so the two stay in sync.
*/
function toContentWidthPx(bodyValues: any, fallback = 500): number {
- const raw = bodyValues?.contentWidth;
- if (typeof raw === "number" && Number.isFinite(raw)) return raw;
- if (typeof raw === "string") {
- const n = parseInt(raw, 10);
- if (Number.isFinite(n)) return n;
- }
- return fallback;
+ return bodyContentWidthPx(bodyValues?.contentWidth, fallback);
}
function renderRowToHtml(innerHTML: string, values: any, bodyValues: any, mode: RenderMode, cells: number[], collection: string = "rows"): string {
diff --git a/packages/react/src/dx-behaviors.test.tsx b/packages/react/src/dx-behaviors.test.tsx
index f1a44e5..d74a962 100644
--- a/packages/react/src/dx-behaviors.test.tsx
+++ b/packages/react/src/dx-behaviors.test.tsx
@@ -45,17 +45,19 @@ const columnValues = (el: React.ReactElement) =>
) as any).body.rows[0].columns[0].values;
-describe("DX: image sizing (Unlayer model — width is natural, display is a percent)", () => {
- it("a numeric width is the natural size, stays responsive (autoWidth:true)", () => {
+describe("DX: image sizing (a fixed width pins to the editor's canonical percent)", () => {
+ // Default Body contentWidth is 500px; minus the 10px×2 default container
+ // padding the content slot is 480px, so a 300px pin → 300/480 = 62.5%.
+ it("a numeric width pins (autoWidth:false + percent of the content slot)", () => {
const src = itemValues().src;
- expect(src.autoWidth).toBe(true);
- expect(src.width).toBe(300);
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("62.5%");
});
- it("a px-string width is the natural size, stays responsive", () => {
+ it("a px-string width pins identically to the numeric form", () => {
const src = itemValues().src;
- expect(src.autoWidth).toBe(true);
- expect(src.width).toBe(300);
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("62.5%");
});
it("a percent width is a fixed display size (autoWidth:false + maxWidth percent)", () => {
@@ -85,8 +87,10 @@ describe("DX: image sizing (Unlayer model — width is natural, display is a per
expect(html()).toMatch(/
]*src="https:\/\/x\/a\.png"/);
});
- it("a non-percent maxWidth does not pin (only a percent sets a fixed display)", () => {
- expect(itemValues().src.autoWidth).toBe(true);
+ it("a px maxWidth pins as a percent of the content slot", () => {
+ const src = itemValues().src;
+ expect(src.autoWidth).toBe(false);
+ expect(src.maxWidth).toBe("62.5%");
});
// Guards the column-overflow regression: a dimensioned image (object src.width
diff --git a/packages/react/src/utils/create-component.tsx b/packages/react/src/utils/create-component.tsx
index dec14ce..8d993c7 100644
--- a/packages/react/src/utils/create-component.tsx
+++ b/packages/react/src/utils/create-component.tsx
@@ -13,6 +13,7 @@ import type { ExporterName } from "@unlayer/types";
type ItemExporters = Partial string>>;
import type { RenderMode, UnlayerConfig } from "@unlayer-internal/shared-elements";
import type { SizeInput } from "../types";
+import { contentSlotWidth, pinImageSrc } from "./image-sizing";
import {
mergeValues,
generateHtmlFromTextJson,
@@ -309,6 +310,26 @@ export function createItemComponent<
config.name
);
+ // 5b. Convert a fixed (px) image pin to the editor's canonical percent now
+ // that the column geometry is known (Column threads columnValues/cells/
+ // bodyValues). Guarded on `src.autoWidth === false`, so only pinned
+ // images are touched; responsive images and non-image blocks pass through.
+ const exportSrc = (valuesForExporter as Record).src;
+ if (exportSrc && typeof exportSrc === "object" && exportSrc.autoWidth === false) {
+ const availableWidth = contentSlotWidth({
+ bodyValues: safeBodyValues,
+ rowValues,
+ rowCells: cells,
+ columnIndex: colIndex,
+ columnValues,
+ containerPadding:
+ (props as { containerPadding?: unknown; values?: { containerPadding?: unknown } })
+ .containerPadding ??
+ (props as { values?: { containerPadding?: unknown } }).values?.containerPadding,
+ });
+ (valuesForExporter as Record).src = pinImageSrc(exportSrc, availableWidth);
+ }
+
// 6. Resolve exporter for this mode (fallback to web)
const exporter = (config.exporters[mode] || config.exporters.web)!;
diff --git a/packages/react/src/utils/image-sizing.ts b/packages/react/src/utils/image-sizing.ts
new file mode 100644
index 0000000..ae94561
--- /dev/null
+++ b/packages/react/src/utils/image-sizing.ts
@@ -0,0 +1,172 @@
+/**
+ * Image display-width resolution.
+ *
+ * In Unlayer's value model an image's display size is `autoWidth` + `maxWidth`,
+ * kept separate from the natural `src.width`/`height`. A fixed display size is
+ * `autoWidth: false` with `maxWidth` as a PERCENT of the column's content slot
+ * (e.g. `"50%"`) — not the natural-size field. Encoding a fixed width that way
+ * keeps it stable when the design JSON is opened in an editor, which treats
+ * `src.width`/`height` as the intrinsic dimensions and may refresh them.
+ *
+ * Authors pass pixels, so a px pin is captured as `{ autoWidth:false,
+ * maxWidth: }` and converted here to the equivalent percent using the same
+ * available-width geometry the renderers use (contentWidth × column share, minus
+ * paddings/borders), so the emitted value renders at the requested on-screen size.
+ */
+
+/** Parse a CSS length to px, strictly: a number or a numeric string with an
+ * optional px unit ("12" / "12px"). Non-px units ("1.5em", "calc(…)") return
+ * undefined so the pinning pass leaves them untouched instead of misreading
+ * them as px (SizeInput allows non-px CSS strings). */
+function toPx(value: unknown): number | undefined {
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
+ if (typeof value !== "string") return undefined;
+ const m = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(value.trim());
+ return m ? parseFloat(m[1]) : undefined;
+}
+
+// NOTE: the geometry parsers below use parseFloat on each token, intentionally
+// mirroring the renderer's explodePaddingsOrMargins / explodeBorder (which the
+// image exporter subtracts from the available width). The renderer reads a token
+// like "10%" as its numeric value (10), so the slot math must too — switching to
+// strict px parsing here would make the pinned percent diverge from what the
+// editor actually renders. Strict px parsing is only for the display-pin value
+// (toPx, in pinImageSrc), never for the geometry.
+
+/** Left/right edge sizes from a CSS box shorthand (padding/margin), parsed the
+ * same way the renderer's explodePaddingsOrMargins does (parseFloat per token). */
+function edges(value: unknown): { left: number; right: number } {
+ if (value == null) return { left: 0, right: 0 };
+ if (typeof value === "number") return { left: value, right: value };
+ const parts = String(value)
+ .trim()
+ .split(/\s+/)
+ .map((p) => parseFloat(p) || 0);
+ // CSS order: 1 = all; 2 = [v h]; 3 = [t h b]; 4 = [t r b l].
+ if (parts.length === 1) return { left: parts[0], right: parts[0] };
+ if (parts.length === 2 || parts.length === 3)
+ return { left: parts[1], right: parts[1] };
+ return { left: parts[3] || 0, right: parts[1] || 0 };
+}
+
+/** Left/right border widths from a per-side border object, parsed the same way
+ * the renderer's explodeBorder does (parseFloat per width). */
+function borderEdges(border: unknown): { left: number; right: number } {
+ if (!border || typeof border !== "object") return { left: 0, right: 0 };
+ const b = border as Record;
+ const width = (v: unknown) => parseFloat(`${v ?? ""}`) || 0;
+ return {
+ left: width(b.borderLeftWidth),
+ right: width(b.borderRightWidth),
+ };
+}
+
+/** A body `contentWidth` is "fixed px" only when it's a number or a numeric
+ * string with an optional px unit (`600`, `"600px"`, or `"600"`). Percentages
+ * and keywords like `"auto"` are NOT fixed → `undefined`. */
+function fixedContentWidth(contentWidth: unknown): number | undefined {
+ if (typeof contentWidth === "number")
+ return Number.isFinite(contentWidth) ? contentWidth : undefined;
+ if (typeof contentWidth === "string") {
+ const m = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(contentWidth.trim());
+ if (m) return parseFloat(m[1]);
+ }
+ return undefined;
+}
+
+/** Unlayer's body content width fallback when `contentWidth` isn't fixed px. */
+const FALLBACK_BODY_CONTENT_WIDTH = 500;
+/** Unlayer's default content-block padding when a block sets none. */
+const DEFAULT_CONTAINER_PADDING = "10px";
+
+/**
+ * A body `contentWidth` resolved to px: the fixed px value, or `fallback`
+ * (500, Unlayer's base width) for non-px values like `"50%"` / `"auto"`.
+ *
+ * Shared with Row's grid CSS (`toContentWidthPx`) so the slot geometry here and
+ * the renderer's column math agree on what counts as a fixed width — and so a
+ * non-px `contentWidth` collapses to the same base everywhere instead of being
+ * `parseInt`-ed into a bogus px value (e.g. `"50%"` → 50).
+ */
+export function bodyContentWidthPx(
+ contentWidth: unknown,
+ fallback: number = FALLBACK_BODY_CONTENT_WIDTH
+): number {
+ return fixedContentWidth(contentWidth) ?? fallback;
+}
+
+export interface SlotContext {
+ bodyValues?: { contentWidth?: number | string; padding?: unknown; border?: unknown };
+ rowValues?: { padding?: unknown; border?: unknown };
+ rowCells?: number[];
+ columnIndex?: number;
+ columnValues?: { padding?: unknown; border?: unknown };
+ /** The image block's own containerPadding (defaults to 10px like the editor). */
+ containerPadding?: unknown;
+}
+
+/**
+ * Width in px available to a content block, mirroring the renderers'
+ * available-width math: contentWidth, minus body/row/column/container paddings
+ * and borders, scaled by the column's share of the row.
+ */
+export function contentSlotWidth(ctx: SlotContext): number {
+ const { bodyValues = {}, rowValues = {}, columnValues = {} } = ctx;
+ const rowCells = ctx.rowCells && ctx.rowCells.length ? ctx.rowCells : [1];
+ const columnIndex = ctx.columnIndex ?? 0;
+
+ const bodyWidth = bodyContentWidthPx(bodyValues.contentWidth);
+
+ const bp = edges(bodyValues.padding);
+ const bb = borderEdges(bodyValues.border);
+ const bodyAvail = bodyWidth - bp.left - bp.right - bb.left - bb.right;
+
+ const rp = edges(rowValues.padding);
+ const rb = borderEdges(rowValues.border);
+ const rowAvail = bodyAvail - rp.left - rp.right - rb.left - rb.right;
+
+ const rowSpan = rowCells.reduce((a, b) => a + b, 0) || 1;
+ const colSpan = rowCells[columnIndex] || 1;
+ const colWidth = (colSpan / rowSpan) * rowAvail;
+
+ const cp = edges(columnValues.padding);
+ const cb = borderEdges(columnValues.border);
+ const colAvail = colWidth - cp.left - cp.right - cb.left - cb.right;
+
+ const ip = edges(ctx.containerPadding ?? DEFAULT_CONTAINER_PADDING);
+ return colAvail - ip.left - ip.right;
+}
+
+const PERCENT = /^\d+(?:\.\d+)?%$/;
+
+/** Round to 2 decimals, matching the editor's percent precision. */
+function round2(n: number): number {
+ return Math.round(n * 100) / 100;
+}
+
+/**
+ * Convert a px display pin on an image `src` to the editor's canonical percent.
+ *
+ * Only touches a pinned image (`autoWidth === false`) whose `maxWidth` is a
+ * px/number placeholder; a percent `maxWidth` is already canonical and is left
+ * as-is, and a responsive image (`autoWidth !== false`) is untouched. A pin
+ * wider than the slot clamps to 100%, mirroring the editor.
+ */
+export function pinImageSrc | undefined>(
+ src: T,
+ availableWidth: number | undefined
+): T {
+ if (!src || typeof src !== "object") return src;
+ if ((src as Record).autoWidth !== false) return src;
+
+ const maxWidth = (src as Record).maxWidth;
+ if (typeof maxWidth === "string" && PERCENT.test(maxWidth.trim())) return src;
+
+ const pinPx = toPx(maxWidth);
+ if (pinPx == null) return src;
+
+ const avail = availableWidth && availableWidth > 0 ? availableWidth : undefined;
+ const pct = avail ? (pinPx >= avail ? 100 : round2((pinPx / avail) * 100)) : 100;
+
+ return { ...src, autoWidth: false, maxWidth: `${pct}%` } as T;
+}
diff --git a/packages/react/src/utils/render-to-json.ts b/packages/react/src/utils/render-to-json.ts
index 05852e6..a383647 100644
--- a/packages/react/src/utils/render-to-json.ts
+++ b/packages/react/src/utils/render-to-json.ts
@@ -27,6 +27,14 @@ const schemaVersion: number = _schemaVersion ?? 24;
import { mapSemanticProps } from "./semantic-props";
import { UNLAYER_CONFIG_KEY } from "./create-component";
import { BODY_DEFAULTS, ROW_DEFAULTS, COLUMN_DEFAULTS } from "./container-defaults";
+import { contentSlotWidth, pinImageSrc, type SlotContext } from "./image-sizing";
+
+/** Layout context threaded down the walk so an image can be sized against the
+ * real column slot (contentWidth × column share, minus paddings/borders). */
+type LayoutContext = Pick<
+ SlotContext,
+ "bodyValues" | "rowValues" | "rowCells" | "columnIndex" | "columnValues"
+>;
// ============================================
// Tree helpers (inlined)
@@ -186,7 +194,8 @@ function extractTextFromTextJson(textJson: string): string {
function processItem(
element: React.ReactElement,
- counters: Record
+ counters: Record,
+ layout: LayoutContext = {}
): DesignContent {
const componentType = element.type as any;
const config = componentType[UNLAYER_CONFIG_KEY];
@@ -232,12 +241,25 @@ function processItem(
hideable: true,
};
+ // Convert a fixed (px) image pin to a percent of the column slot using the
+ // threaded geometry, so the display width survives the JSON round-trip into an
+ // editor. Guarded on `src.autoWidth === false`, so only pinned images change.
+ const itemSrc = (values as Record).src;
+ if (itemSrc && typeof itemSrc === "object" && itemSrc.autoWidth === false) {
+ const availableWidth = contentSlotWidth({
+ ...layout,
+ containerPadding: (values as Record).containerPadding,
+ });
+ (values as Record).src = pinImageSrc(itemSrc, availableWidth);
+ }
+
return { type: contentType, values };
}
function processColumn(
element: React.ReactElement,
- counters: Record
+ counters: Record,
+ layout: Omit = {}
): DesignColumn {
const count = nextCounter(counters, "u_column");
const id = makeId("u_column", count);
@@ -261,8 +283,9 @@ function processColumn(
const contents: DesignContent[] = [];
const children = collectChildren(element.props.children);
+ const itemLayout: LayoutContext = { ...layout, columnValues: valuesWithMeta };
for (const child of children) {
- contents.push(processItem(child, counters));
+ contents.push(processItem(child, counters, itemLayout));
}
return { contents, values: valuesWithMeta };
@@ -270,7 +293,8 @@ function processColumn(
function processRow(
element: React.ReactElement,
- counters: Record
+ counters: Record,
+ parentLayout: Pick = {}
): DesignRow {
const count = nextCounter(counters, "u_row");
const id = makeId("u_row", count);
@@ -283,10 +307,15 @@ function processRow(
} else if (propsCells) {
cells = propsCells;
} else {
- // Default: one cell per Column child
+ // Default: one equal cell per Column child. Count only children —
+ // any stray non-Column child is warned and skipped below, so including it
+ // would make `cells` longer than the column list and distort both the row
+ // layout and the column-share math used for image sizing.
const columnCount = Math.max(
1,
- collectChildren(element.props.children).length
+ collectChildren(element.props.children).filter(
+ (child) => getDisplayName(child) === "Column"
+ ).length
);
cells = Array(columnCount).fill(1);
}
@@ -317,10 +346,19 @@ function processRow(
const columns: DesignColumn[] = [];
const children = collectChildren(element.props.children);
+ const columnLayout: Omit = {
+ bodyValues: parentLayout.bodyValues,
+ rowValues: valuesWithMeta,
+ rowCells: cells,
+ };
+ let columnIndex = 0;
for (const child of children) {
const name = getDisplayName(child);
if (name === "Column") {
- columns.push(processColumn(child, counters));
+ columns.push(
+ processColumn(child, counters, { ...columnLayout, columnIndex })
+ );
+ columnIndex += 1;
} else {
console.warn(
`[Unlayer] renderToJson: <${name}> is not a valid Row child. Only is allowed.`
@@ -367,7 +405,7 @@ function processBody(
for (const child of children) {
const name = getDisplayName(child);
if (name === "Row") {
- rows.push(processRow(child, counters));
+ rows.push(processRow(child, counters, { bodyValues: valuesWithMeta }));
} else {
console.warn(
`[Unlayer] renderToJson: <${name}> is not a valid Body child. Only is allowed.`