fix(pdf): draw azurill's timeline line under forme

Forme gives an absolute box no height from its top and bottom offsets,
so the bar that react-pdf stretched down each section's items vanished.
The converter now draws such a bar as the left border of a box around
the other children, which is as tall as they are and is painted on
every page the section reaches.
This commit is contained in:
Amruth Pillai
2026-09-29 11:06:20 +02:00
parent 7c33ebae11
commit ab2e263a2c
2 changed files with 68 additions and 4 deletions
+66 -2
View File
@@ -351,6 +351,70 @@ function unreverseRow(spread: { style: FormeStyle; children: ReactNode[] }): {
};
}
// What lays a container's children out, as opposed to what sizes, spaces and paints the container.
const CONTAINER_KEYS = new Set([
"flexDirection",
"flexWrap",
"justifyContent",
"alignItems",
"alignContent",
"rowGap",
"columnGap",
"gap",
]);
/**
* Forme 0.25 gives an absolute box no height from its `top` and `bottom`, where Yoga stretched it between them
* (Azurill's timeline line). A plain bar down the whole content box becomes the left border of a box around the other
* children instead: that box is as tall as they are, and its border is drawn on every page it reaches. A box that
* merely stretches along a row keeps its whole height on the page where the row breaks.
*/
function barAsBorder(spread: { style: FormeStyle; children: ReactNode[] }): {
style: FormeStyle;
children: ReactNode[];
} {
const isBar = (child: ReactNode): child is ReactElement<{ style: FormeStyle }> => {
if (!isValidElement<{ style?: FormeStyle }>(child)) return false;
const { position, height, top, bottom, left, width, backgroundColor } = child.props.style ?? {};
return (
position === "absolute" &&
height === undefined &&
top === 0 &&
bottom === 0 &&
typeof left === "number" &&
typeof width === "number" &&
width > 0 &&
typeof backgroundColor === "string"
);
};
const bar = spread.children.find(isBar);
if (!bar) return spread;
const { left, width, backgroundColor } = bar.props.style as { left: number; width: number; backgroundColor: string };
const outer: Record<string, unknown> = {};
const inner: Record<string, unknown> = { position: "relative", flexGrow: 1 };
for (const [property, value] of Object.entries(spread.style))
(CONTAINER_KEYS.has(property) ? inner : outer)[property] = value;
// Forme strokes a border centred on the box's edge, so the edge sits half the bar's width in.
const edge = left + width / 2;
inner.marginLeft = -(edge + width);
const content = createElement(
FormeView,
{ key: "content", style: inner as FormeStyle },
...spread.children.filter((child) => child !== bar),
);
const bordered = createElement(
FormeView,
{
key: "bar",
style: { flexGrow: 1, marginLeft: edge, borderLeftWidth: width, borderLeftColor: backgroundColor },
},
content,
);
return { style: outer as FormeStyle, children: [bordered] };
}
function convertChildren(children: HostNode[], context: Context): ReactNode[] {
return children.map((child, index) => convertNode(child, context, index));
}
@@ -378,8 +442,8 @@ function convertNode(node: HostNode, parentContext: Context, key: number): React
const converted = convertStyle(props.style, context, node);
const { style } = converted;
if (converted.hidden) return null;
const spread = unreverseRow(
spreadRowGap(style, convertChildren(node.children, childContext(context, converted)), context),
const spread = barAsBorder(
unreverseRow(spreadRowGap(style, convertChildren(node.children, childContext(context, converted)), context)),
);
let children = spread.children;
const viewStyle = flowStyle(props, spread.style);
@@ -4,8 +4,8 @@
"textItemCount": 87,
"missingMarkers": [],
"rasterSha256": [
"1dde504c195a01a4195ea35631bb7fbe7fb0d5322fca096c0fb5c4c06fe987ea",
"9acc4ff81096bd83c04058e00e905963aa4d21b7683e0c8c757924f5c394f61d"
"689ba999c173466faab5f60ad03c081e5e7a4def9a7acbfa88583652030c42a9",
"e8b5f42ff68f9527c2ce103bddbf5453d8e0220d68c975384e1e93f56f01a764"
],
"coordinates": {
"EXP_LONG": [