diff --git a/CHANGELOG.md b/CHANGELOG.md
index a34e704d..976fa343 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 —
diff --git a/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java b/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java
index 5c964690..68f0c43c 100644
--- a/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java
+++ b/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java
@@ -45,12 +45,24 @@ static LayoutGraph apply(LayoutGraph base, List 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(),
diff --git a/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java b/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java
index 3d7ae990..8b73efc3 100644
--- a/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java
+++ b/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java
@@ -29,9 +29,13 @@
* narrow accent column over a full-page tint.
*
* @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,
diff --git a/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java b/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java
index 6cb8c6ee..b4cae9b2 100644
--- a/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java
+++ b/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java
@@ -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 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 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 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