17
UTF-8
- 0.3.1
+ 0.4.0
4.7.6
6.1.1
3.27.7
diff --git a/docs/theming.md b/docs/theming.md
index 5d4b1c7..8631120 100644
--- a/docs/theming.md
+++ b/docs/theming.md
@@ -26,7 +26,7 @@ Pure cosmetic values, grouped:
| `TypographyTokens` | body / heading / code font families, body & code sizes, line spacing, the six heading sizes |
| `SpacingTokens` | block gaps, paddings, table cell padding |
| `ShapeTokens` | corner radii, border/line weights |
-| `PageTokens` | page size, margins, content width |
+| `PageTokens` | page size, margins, content width, `keepHeadingWithNext` |
| `SyntaxColors` | code highlight colors (keyword, string, comment, number, annotation, function) |
Swap a token group to reskin everything that derives from it. Tokens are immutable
@@ -38,6 +38,16 @@ MarkdownTokens tokens = base.tokens()
.withSyntax(SyntaxColors.defaultDark());
```
+`PageTokens.keepHeadingWithNext` (default `true`) is the one non-cosmetic token: it
+decides whether a heading may be left stranded as the last block on a page, apart
+from the content it introduces. Set it to `false` for the plain flow:
+
+```java
+PageTokens page = base.tokens().page();
+MarkdownTokens tokens = base.tokens().withPage(
+ new PageTokens(page.pageSize(), page.margin(), page.contentWidth(), false));
+```
+
## Layer 2 — component styles (`MarkdownStyles`)
`MarkdownStyles` derives per-element styles (`CodeBlockStyle`, `QuoteStyle`,
diff --git a/examples/README.md b/examples/README.md
index 02a15ff..4f62b98 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -11,7 +11,7 @@ From the repository root:
./mvnw -B -ntp -DskipTests install
```
-This installs `io.github.demchaav:graph-compose-markdown:0.3.1` into your
+This installs `io.github.demchaav:graph-compose-markdown:0.4.0` into your
local Maven repository, which these examples depend on.
## 2. Run an example
diff --git a/examples/pom.xml b/examples/pom.xml
index 5fe91eb..3291845 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -15,7 +15,7 @@
-->
io.github.demchaav
graph-compose-markdown-examples
- 0.3.1
+ 0.4.0
jar
graph-compose-markdown examples
@@ -26,7 +26,7 @@
UTF-8
- 0.3.1
+ 0.4.0
true
true
diff --git a/pom.xml b/pom.xml
index 1f62e95..1fdf68d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.demchaav
graph-compose-markdown
- 0.3.1
+ 0.4.0
GraphCompose Markdown
A themeable Markdown document composer powered by the GraphCompose layout engine.
@@ -46,7 +46,7 @@
17
- 1.9.1
+ 2.1.1
1.0.0
1.0.0
0.64.8
diff --git a/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java b/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java
index 0a61dae..dc4064e 100644
--- a/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java
+++ b/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java
@@ -1,5 +1,6 @@
package io.github.demchaav.markdown.render;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.RichText;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.image.DocumentImageFitMode;
@@ -29,10 +30,12 @@
import io.github.demchaav.markdown.theme.style.InlineStyle;
import io.github.demchaav.markdown.theme.style.MarkdownStyles;
import io.github.demchaav.markdown.theme.tokens.AlertColors;
+import io.github.demchaav.markdown.theme.tokens.PageTokens;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import java.util.function.Consumer;
/**
* The default {@link NodeRenderer} registration plus the smaller built-in renderers.
@@ -76,6 +79,15 @@ public static void registerDefaults(RendererRegistry registry) {
* bookmark (outline entry) at its level so the rendered document gets a navigable
* heading tree in the viewer's outline pane, and declares a GitHub-style anchor so
* {@code [text](#heading)} links can jump to it.
+ *
+ * Unless the theme turns {@link PageTokens#keepHeadingWithNext()} off, the
+ * paragraph is wrapped in a one-child section marked
+ * {@link SectionBuilder#keepWithNext()}, so a heading that would otherwise be
+ * stranded at the bottom of a page — with nothing or a single line of its body
+ * under it — moves down to join the block it introduces. The flag lives on the
+ * section because it is a node-level pagination property and a paragraph cannot
+ * carry it; the bookmark and the anchor stay on the paragraph, so outline and
+ * link destinations resolve to the heading's own position exactly as before.
*/
public static final class HeadingRenderer implements NodeRenderer {
@Override
@@ -88,12 +100,17 @@ public void render(HeadingNode node, SectionBuilder host, RenderContext ctx) {
// falling back to an on-the-fly slug if this heading was somehow not planned.
String planned = ctx.headingSlug(node);
String anchor = planned != null ? planned : ctx.headingAnchor(title);
- host.addParagraph(p -> {
+ Consumer paragraph = p -> {
p.rich(rich).margin(new DocumentInsets(above, 0, 0, 0)).anchor(anchor);
if (!title.isEmpty()) {
p.bookmark(new DocumentBookmarkOptions(title, node.level()));
}
- });
+ };
+ if (ctx.tokens().page().keepHeadingWithNext()) {
+ host.addSection(s -> s.keepWithNext().addParagraph(paragraph));
+ } else {
+ host.addParagraph(paragraph);
+ }
}
}
diff --git a/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java b/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java
index 026f29a..415ea55 100644
--- a/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java
+++ b/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java
@@ -6,17 +6,31 @@
import java.util.Objects;
/**
- * Page geometry tokens.
+ * Page geometry and pagination tokens.
*
- * @param pageSize the page size
- * @param margin the page margin (top, right, bottom, left)
- * @param contentWidth the usable content width in points (page width minus left/right margin)
+ * @param pageSize the page size
+ * @param margin the page margin (top, right, bottom, left)
+ * @param contentWidth the usable content width in points (page width minus left/right margin)
+ * @param keepHeadingWithNext whether a heading is kept with the block it introduces across a page
+ * break (a theme can set {@code false} to restore the plain flow)
*/
-public record PageTokens(DocumentPageSize pageSize, DocumentInsets margin, double contentWidth) {
+public record PageTokens(DocumentPageSize pageSize, DocumentInsets margin, double contentWidth,
+ boolean keepHeadingWithNext) {
/** Validates the page size and margin are present. */
public PageTokens {
Objects.requireNonNull(pageSize, "pageSize");
Objects.requireNonNull(margin, "margin");
}
+
+ /**
+ * Creates page tokens that keep headings with their content (the default).
+ *
+ * @param pageSize the page size
+ * @param margin the page margin (top, right, bottom, left)
+ * @param contentWidth the usable content width in points
+ */
+ public PageTokens(DocumentPageSize pageSize, DocumentInsets margin, double contentWidth) {
+ this(pageSize, margin, contentWidth, true);
+ }
}
diff --git a/src/test/java/io/github/demchaav/markdown/HeadingKeepWithNextTest.java b/src/test/java/io/github/demchaav/markdown/HeadingKeepWithNextTest.java
new file mode 100644
index 0000000..8d139e0
--- /dev/null
+++ b/src/test/java/io/github/demchaav/markdown/HeadingKeepWithNextTest.java
@@ -0,0 +1,213 @@
+package io.github.demchaav.markdown;
+
+import io.github.demchaav.markdown.composer.MarkdownComposer;
+import io.github.demchaav.markdown.theme.DefaultMarkdownTheme;
+import io.github.demchaav.markdown.theme.MarkdownTheme;
+import io.github.demchaav.markdown.theme.tokens.MarkdownTokens;
+import io.github.demchaav.markdown.theme.tokens.PageTokens;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDDestination;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDNamedDestination;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A heading is never left stranded as the last block on a page, apart from the
+ * content it introduces. The renderer wraps the heading paragraph in a one-child
+ * section marked {@code keepWithNext()} — the engine's opt-in orphan rule — so a
+ * heading that cannot share its page with the first line of the following block
+ * moves down to join it.
+ *
+ * The filler count is tuned so the heading lands exactly in the window where
+ * the heading itself still fits but its body's first line does not: that is the
+ * only situation the rule acts on, and the {@code false} case pins the untreated
+ * behaviour so the test would fail if the wrapping silently stopped applying.
+ */
+class HeadingKeepWithNextTest {
+
+ /** The filler count that puts the heading at the bottom of page 1 with no room for its body. */
+ private static final int ORPHANING_FILLER = 32;
+
+ private static final String HEADING = "Target heading";
+ private static final String BODY_OPENING = "Body sentence that belongs";
+
+ private static String markdown(int fillerParagraphs) {
+ StringBuilder sb = new StringBuilder("# Doc\n\n");
+ for (int i = 0; i < fillerParagraphs; i++) {
+ sb.append("Filler paragraph number ").append(i)
+ .append(" with enough words to occupy a full line of the page body text.\n\n");
+ }
+ return sb.append("## ").append(HEADING).append("\n\n")
+ .append(BODY_OPENING).append(" under the target heading and must not be torn from it.\n\n")
+ .append("More body so the section is not trivially short and keeps flowing after the break.\n")
+ .toString();
+ }
+
+ private static MarkdownTheme theme(boolean keepHeadingWithNext) {
+ MarkdownTheme base = DefaultMarkdownTheme.light();
+ MarkdownTokens tokens = base.tokens();
+ PageTokens page = tokens.page();
+ return MarkdownTheme.builder(base)
+ .tokens(tokens.withPage(new PageTokens(
+ page.pageSize(), page.margin(), page.contentWidth(), keepHeadingWithNext)))
+ .build();
+ }
+
+ /**
+ * @return the 1-based page carrying {@code needle}
+ * @throws AssertionError when no page does — otherwise a broken render would let
+ * two "not found" results compare equal and pass silently
+ */
+ private static int pageOf(byte[] pdf, String needle) throws Exception {
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ for (int page = 1; page <= doc.getNumberOfPages(); page++) {
+ PDFTextStripper stripper = new PDFTextStripper();
+ stripper.setStartPage(page);
+ stripper.setEndPage(page);
+ if (stripper.getText(doc).contains(needle)) {
+ return page;
+ }
+ }
+ throw new AssertionError("'" + needle + "' is on no page of the rendered document");
+ }
+ }
+
+ private static byte[] render(String markdown, boolean keepHeadingWithNext) {
+ return MarkdownComposer.builder()
+ .theme(theme(keepHeadingWithNext))
+ .build()
+ .render(markdown)
+ .toPdfBytes();
+ }
+
+ @Test
+ void defaultThemeKeepsHeadingsWithTheirContent() {
+ assertThat(DefaultMarkdownTheme.light().tokens().page().keepHeadingWithNext()).isTrue();
+ assertThat(DefaultMarkdownTheme.dark().tokens().page().keepHeadingWithNext()).isTrue();
+ }
+
+ @Test
+ void headingMovesDownRatherThanStrandingAtThePageBottom() throws Exception {
+ byte[] pdf = render(markdown(ORPHANING_FILLER), true);
+
+ assertThat(pageOf(pdf, HEADING))
+ .as("the heading shares a page with the body it introduces")
+ .isEqualTo(pageOf(pdf, BODY_OPENING));
+ }
+
+ @Test
+ void withoutTheTokenTheHeadingStrandsAtThePageBottom() throws Exception {
+ byte[] pdf = render(markdown(ORPHANING_FILLER), false);
+
+ assertThat(pageOf(pdf, HEADING))
+ .as("the untreated flow leaves the heading behind on the previous page")
+ .isLessThan(pageOf(pdf, BODY_OPENING));
+ }
+
+ /**
+ * The rule only acts in the narrow window where the heading strands; a heading
+ * with room for its body is placed exactly as before, on both settings.
+ */
+ @Test
+ void headingWithRoomForItsBodyIsUnaffected() throws Exception {
+ String md = markdown(10);
+
+ assertThat(pageOf(render(md, true), HEADING)).isEqualTo(pageOf(render(md, false), HEADING));
+ }
+
+ /**
+ * The outline entry points at the page the heading landed on after relocating,
+ * rather than the one it left. The wrapper carries the whole heading and moves as
+ * a unit, so this holds whichever node the bookmark rides — what it guards is the
+ * relocation itself staying in step with the outline, not the choice of node.
+ * (That choice is visible only in the destination's Y: a bookmark on the wrapper
+ * would point at the top of the heading's space-above margin instead of at the
+ * text, ~8 pt higher for an H2 in the default theme.)
+ */
+ @Test
+ void theBookmarkFollowsTheRelocatedHeading() throws Exception {
+ byte[] pdf = render(markdown(ORPHANING_FILLER), true);
+ int headingPage = pageOf(pdf, HEADING);
+ assertThat(headingPage).as("the sample really does relocate the heading").isEqualTo(2);
+
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ PDOutlineItem bookmark = doc.getDocumentCatalog().getDocumentOutline().getFirstChild();
+ while (bookmark != null && !HEADING.equals(bookmark.getTitle())) {
+ bookmark = bookmark.getFirstChild() != null && HEADING.equals(bookmark.getFirstChild().getTitle())
+ ? bookmark.getFirstChild()
+ : bookmark.getNextSibling();
+ }
+ assertThat(bookmark).as("the heading has an outline entry").isNotNull();
+ assertThat(destinationPage(doc, bookmark))
+ .as("the outline entry points at the page the heading landed on")
+ .isEqualTo(headingPage);
+ }
+ }
+
+ /**
+ * A {@code [text](#slug)} link to the relocated heading resolves to the page it
+ * landed on. The anchor rides the same paragraph as the bookmark, but it travels
+ * as a named destination rather than an outline entry, so it is a distinct path.
+ */
+ @Test
+ void anAnchorLinkFollowsTheRelocatedHeading() throws Exception {
+ String md = "[jump](#target-heading)\n\n" + markdown(ORPHANING_FILLER);
+ byte[] pdf = render(md, true);
+ int headingPage = pageOf(pdf, HEADING);
+
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ List targets = new ArrayList<>();
+ for (PDPage page : doc.getPages()) {
+ for (PDAnnotation annotation : page.getAnnotations()) {
+ if (annotation instanceof PDAnnotationLink link) {
+ PDDestination destination = link.getDestination();
+ if (destination == null && link.getAction() instanceof PDActionGoTo goTo) {
+ destination = goTo.getDestination();
+ }
+ if (destination != null) {
+ targets.add(resolvePage(doc, destination));
+ }
+ }
+ }
+ }
+ assertThat(targets).as("the anchor link resolves to the heading's page")
+ .containsExactly(headingPage);
+ }
+ }
+
+ /** @return the 1-based page an outline item points at */
+ private static int destinationPage(PDDocument doc, PDOutlineItem item) throws Exception {
+ PDDestination destination = item.getDestination();
+ if (destination == null && item.getAction() instanceof PDActionGoTo goTo) {
+ destination = goTo.getDestination();
+ }
+ return resolvePage(doc, destination);
+ }
+
+ /** @return the 1-based page a destination resolves to, or -1 when it cannot be resolved */
+ private static int resolvePage(PDDocument doc, PDDestination destination) throws Exception {
+ PDDestination resolved = destination;
+ if (resolved instanceof PDNamedDestination named) {
+ resolved = doc.getDocumentCatalog().findNamedDestinationPage(named);
+ }
+ if (resolved instanceof PDPageDestination pageDestination) {
+ int number = pageDestination.retrievePageNumber();
+ return number >= 0
+ ? number + 1
+ : doc.getPages().indexOf(pageDestination.getPage()) + 1;
+ }
+ return -1;
+ }
+}
diff --git a/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java b/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java
index 7875bf6..a7fccd2 100644
--- a/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java
+++ b/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java
@@ -6,6 +6,7 @@
import com.demcha.compose.document.node.InlineRun;
import com.demcha.compose.document.node.InlineShapeRun;
import com.demcha.compose.document.node.InlineTextRun;
+import com.demcha.compose.document.node.ExternalLinkTarget;
import com.demcha.compose.document.node.InternalLinkTarget;
import io.github.demchaav.markdown.extension.ImageResolver;
import io.github.demchaav.markdown.model.inline.CodeRun;
@@ -85,7 +86,10 @@ void inlineImageInsideALinkCarriesTheLinkAnnotation() {
InlineImageRun image = (InlineImageRun) rich.runs().stream()
.filter(InlineImageRun.class::isInstance).findFirst().orElseThrow();
- assertThat(image.linkOptions()).isNotNull(); // the surrounding link is threaded onto the image
+ // the surrounding link is threaded onto the image as an external-URI target
+ assertThat(image.linkTarget()).isInstanceOf(ExternalLinkTarget.class);
+ assertThat(((ExternalLinkTarget) image.linkTarget()).options().uri())
+ .isEqualTo("https://example.com");
}
@Test
@@ -162,7 +166,11 @@ void externalLinkStillCarriesAUriTarget() {
List.of(new LinkRun("https://example.com", null, List.of(new TextRun("site")))), BASE);
InlineTextRun run = (InlineTextRun) rich.runs().get(0);
- assertThat(run.linkOptions()).as("external link keeps its URI options").isNotNull();
+ assertThat(run.linkTarget())
+ .as("external link keeps its URI target")
+ .isInstanceOf(ExternalLinkTarget.class);
+ assertThat(((ExternalLinkTarget) run.linkTarget()).options().uri())
+ .isEqualTo("https://example.com");
}
private static byte[] png(int width, int height) {