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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ follow semantic versioning; release dates are ISO 8601.
regression baselines, and reusable `Subheadline` /
`SectionHeader.flatSpacedCaps` widget support.

### Bug fixes

- **`PageBackgroundFill` y-coordinate.** A partial-height page-background
fill (`heightRatio < 1.0`) was painted from the page **bottom** upward
instead of from the `yRatio` top edge the API documents, so a band with
`yRatio = 0` rendered at the bottom of the page. Fills now convert the
top-down ratios to the PDF bottom-up origin correctly
(`y = (1 - yRatio - heightRatio) * pageHeight`); full-page and
full-height column fills are unchanged. Adds top-/bottom-/mid-band
regression tests.

## v1.6.4 — 2026-05-22

Bug fix + structured-block patch. Adds two new public Block types —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,24 @@ static LayoutGraph apply(LayoutGraph base, List<PageBackgroundFill> fills) {
for (int page = 0; page < base.totalPages(); page++) {
for (int i = 0; i < fills.size(); i++) {
PageBackgroundFill fill = fills.get(i);
// Fill ratios are top-down (yRatio 0.0 = top edge) but
// PlacedFragment.y is the PDF-native bottom-left origin
// (y grows up — see PdfShapeFragmentRenderHandler, which
// calls addRect(x, y, w, h) with (x, y) as the bottom-left).
// A band occupies [yRatio, yRatio + heightRatio] measured
// from the top, so its bottom edge measured from the page
// bottom is (1 - yRatio - heightRatio) * pageHeight. For a
// full-height fill (heightRatio 1.0) this is 0.0 — identical
// to the previous behaviour, so existing full-page/column
// fills are unaffected.
double fragmentY =
(1.0 - fill.yRatio() - fill.heightRatio()) * pageHeight;
combined.add(new PlacedFragment(
"@page-background[" + page + "][" + i + "]",
0,
page,
fill.xRatio() * pageWidth,
fill.yRatio() * pageHeight,
fragmentY,
fill.widthRatio() * pageWidth,
fill.heightRatio() * pageHeight,
com.demcha.compose.engine.components.style.Margin.zero(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@
* narrow accent column over a full-page tint.</p>
*
* @param xRatio 0.0 = left edge, 1.0 = right edge
* @param yRatio 0.0 = top edge, 1.0 = bottom edge
* @param yRatio top edge of the fill: 0.0 = page top, 1.0 = page
* bottom. The fill extends downward from here by
* {@code heightRatio}.
* @param widthRatio width as a fraction of the canvas width (0..1]
* @param heightRatio height as a fraction of the canvas height (0..1]
* @param heightRatio height as a fraction of the canvas height (0..1].
* Keep {@code yRatio + heightRatio <= 1.0} so the fill
* stays within the page.
* @param color fill color (required)
*/
public record PageBackgroundFill(double xRatio,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,91 @@ void pageBackgroundFillFactoryHelpersComputeRectsCorrectly() {
.isEqualTo(new PageBackgroundFill(0.25, 0.0, 0.5, 1.0, c));
}

// -- Partial-height band placement (y-coordinate regression) ---------
// yRatio is top-down (0.0 = page top) but PlacedFragment.y is PDF
// bottom-up, so a partial band must convert via
// (1 - yRatio - heightRatio) * pageHeight. Pre-fix these collapsed to
// y == 0 because every factory used heightRatio == 1; these assert real
// partial bands land at the correct vertical position.

@Test
void topBandAppearsAtTopOfPage() {
DocumentColor band = DocumentColor.of(Color.DARK_GRAY);
try (DocumentSession session = GraphCompose.document()
.pageSize(400, 300)
.margin(DocumentInsets.zero())
.pageBackgrounds(List.of(
new PageBackgroundFill(0.0, 0.0, 1.0, 0.16, band)))
.create()) {

session.add(new SpacerNode("Block", 200, 80,
DocumentInsets.zero(), DocumentInsets.zero()));
List<PlacedFragment> bg = session.layoutGraph().fragments().stream()
.filter(this::isPageBackgroundFragment)
.toList();

assertThat(bg).hasSize(1);
// yRatio 0 = page top → bottom-left at (1 - 0 - 0.16) * 300 = 252,
// NOT 0 (the bottom, which was the pre-fix behaviour).
assertThat(bg.get(0).y()).isCloseTo(252.0, within(EPS));
assertThat(bg.get(0).height()).isCloseTo(48.0, within(EPS));
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@Test
void bottomBandAppearsAtBottomOfPage() {
DocumentColor band = DocumentColor.of(Color.DARK_GRAY);
try (DocumentSession session = GraphCompose.document()
.pageSize(400, 300)
.margin(DocumentInsets.zero())
.pageBackgrounds(List.of(
new PageBackgroundFill(0.0, 0.84, 1.0, 0.16, band)))
.create()) {

session.add(new SpacerNode("Block", 200, 80,
DocumentInsets.zero(), DocumentInsets.zero()));
List<PlacedFragment> bg = session.layoutGraph().fragments().stream()
.filter(this::isPageBackgroundFragment)
.toList();

assertThat(bg).hasSize(1);
// yRatio 0.84 + heightRatio 0.16 = 1.0 → bottom edge flush with
// the page bottom: (1 - 0.84 - 0.16) * 300 = 0.
assertThat(bg.get(0).y()).isCloseTo(0.0, within(EPS));
assertThat(bg.get(0).height()).isCloseTo(48.0, within(EPS));
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@Test
void midPageBandLandsAtCorrectVerticalPosition() {
DocumentColor band = DocumentColor.of(Color.DARK_GRAY);
try (DocumentSession session = GraphCompose.document()
.pageSize(400, 300)
.margin(DocumentInsets.zero())
.pageBackgrounds(List.of(
new PageBackgroundFill(0.0, 0.4, 1.0, 0.2, band)))
.create()) {

session.add(new SpacerNode("Block", 200, 80,
DocumentInsets.zero(), DocumentInsets.zero()));
List<PlacedFragment> bg = session.layoutGraph().fragments().stream()
.filter(this::isPageBackgroundFragment)
.toList();

assertThat(bg).hasSize(1);
// Band spanning 40%..60% from the top → bottom-left at
// (1 - 0.4 - 0.2) * 300 = 120, height 0.2 * 300 = 60.
assertThat(bg.get(0).y()).isCloseTo(120.0, within(EPS));
assertThat(bg.get(0).height()).isCloseTo(60.0, within(EPS));
} catch (Exception e) {
throw new RuntimeException(e);
}
}

private boolean isPageBackgroundFragment(PlacedFragment fragment) {
return fragment.payload() instanceof ShapeFragmentPayload payload
&& payload.fillColor() != null
Expand Down