diff --git a/.gitignore b/.gitignore index 8343ba2..e38f514 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ build/ .claude/ # Internal planning, audit, and roadmap notes that should not ship. docs/private/ +# Scratch sandbox project used to try the library out by hand; not part of the repo. +/GP-Test/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 01eab42..a355bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,35 @@ and the project follows [Semantic Versioning](https://semver.org/). commit's detached-module build; a release-prep commit can no longer tag with the drift. ### Fixed +- **Tables are pinned to GitHub-Flavored-Markdown parity — no more silent content loss.** The + Flexmark tables extension ran with its permissive defaults (column spans, multiple header rows, + trailing `[caption]` lines), all of which the mapper dropped without a trace: a `[caption]` after + a table vanished, and a header-less table mapped to nothing — its whole body disappeared, invisible + even to strict mode. Tables now parse as GFM (`COLUMN_SPANS=false`, one header row, + `WITH_CAPTION=false`, append/discard ragged columns), and a header-less table surfaces its source + as unsupported content (rejected in strict mode, shown verbatim otherwise) rather than being swallowed. +- **List-item continuation blocks keep the item's indent.** A second paragraph, code block or quote + inside a list item bypassed the marker paragraph's margin and rendered flush against the page margin + — left of its own bullet. Continuation blocks now carry the item's left indent (nested lists, which + compute their own indent, are unchanged). +- **Undefined footnote references render as their literal source.** A `[^label]` with no matching + definition was still assigned a number, leaving a dangling superscript and a gap in the footnote run. + It now renders as literal text (the CommonMark behaviour), mirroring how undefined link references are + already handled; defined footnotes number and back-link exactly as before. +- **Heading anchors are always document-unique.** The GitHub-style de-duplicator could hand out the + same `-N` slug twice when a later heading's own text slugified to an already-generated anchor (e.g. + headings `Foo`, `Foo`, `Foo 1`); it now bumps past any taken slug so every heading keeps a unique + anchor that `[TOC]` and `[text](#slug)` links resolve to. +- **Built-in dark themes ship a dark-tuned alert palette.** `DefaultMarkdownTheme.dark()` and + `GitHubTheme.dark()` inherited the light amber/blue alert accents, which fell below a readable + contrast on the near-black surface. A new `AlertColors.defaultDark()` (mirroring + `SyntaxColors.defaultDark()`) gives GitHub-dark-mode accents; both dark themes now use it. +- **Locale-independent lowercasing.** Custom-block type dispatch, `:::` callout variants, image-URL + scheme checks and inline-HTML detection used `String.toLowerCase()` without a locale, so under a + Turkish locale `:::INFO` lowercased to `ınfo` and missed its handler. All now use `Locale.ROOT`. +- **CLI `--version` tracks the module version.** It hard-coded `0.2.0-SNAPSHOT` and had already drifted + stale at the v0.2.0 tag; it now reads the Maven-filtered module version, so it can never diverge from + the pom the version-lockstep guard already enforces. - **Footnotes cited from table cells are bidirectional too.** Cell paragraphs bypass the paragraph renderer, so a footnote first cited inside a table cell had a forward link but a dead back-link; the `fnref-N` back-anchor is now placed on the citing cell as well (list items @@ -20,6 +49,13 @@ and the project follows [Semantic Versioning](https://semver.org/). link — a heading's single anchor slot already carries its navigation slug. ### Changed +- **The custom-renderer seam is now truly open for headings and the table of contents.** The docs + advertise swapping the renderer for *any* node type, but a third-party `NodeRenderer` + or `NodeRenderer` outside the render package had no way to reach the planned heading slugs: + it was forced to call `RenderContext.headingAnchor(title)`, which allocates a *fresh* slug and + silently desynced the anchor from what `[TOC]` and `[text](#slug)` link to. `RenderContext.headingSlug(HeadingNode)` + and `RenderContext.tocEntries()` are now `public`, and `TocEntry` is a public record, so a custom + heading/TOC renderer can declare the same anchors the built-ins do and stay navigable. - **The HTML-entity decoder now handles curly-quote and guillemet entities (all modes).** A literal `’` / `“` / `«` … in the Markdown source now decodes to its character (`’` `“` `«`) instead of rendering as the raw entity text. This applies regardless of the @@ -56,14 +92,6 @@ and the project follows [Semantic Versioning](https://semver.org/). manual two-pass) and every label a clickable jump. Heading nesting indents the label; an optional title row; empty-heading and no-heading documents degrade exactly like the default renderer. Runnable `BookTocExample` (paired with the page-number footer). -- **Book-style page-numbered table of contents — `BookTocRenderer`.** An opt-in alternative to - the default `[TOC]` link list: swap the `TocNode` renderer - (`MarkdownTheme.builder(base).renderer(TocNode.class, new BookTocRenderer("Contents"))`) and - the marker renders as dot-leader contents rows — "Introduction ….. 3" — with **page numbers - resolved automatically from the laid-out document** (the engine's `addTableOfContents`; no - manual two-pass) and every label a clickable jump. Heading nesting indents the label; an - optional title row; empty-heading and no-heading documents degrade exactly like the default - renderer. Runnable `BookTocExample` (paired with the page-number footer). - **Vector colour emoji out of the box — optional `graph-compose-emoji` support.** With the companion artifact on the classpath (declared `optional`, mirroring `graph-compose-fonts`), `:shortcode:` emoji render as crisp Noto vector glyphs at any size — no user-supplied images. @@ -373,5 +401,6 @@ are grouped by area. theme-composition tests; and `EngineRobustnessTest` (code-fence `:::`, unsupported-content handling, strict mode, theme immutability). -[Unreleased]: https://github.com/DemchaAV/graphcompose-markdown/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/DemchaAV/graphcompose-markdown/compare/v0.2.0...HEAD +[v0.2.0]: https://github.com/DemchaAV/graphcompose-markdown/releases/tag/v0.2.0 [v0.1.0]: https://github.com/DemchaAV/graphcompose-markdown/releases/tag/v0.1.0 diff --git a/README.md b/README.md index b40729d..77a4f7e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Markdown ──Flexmark──▶ Flexmark AST ──mapper──▶ Semantic mod types downstream) ``` -> Status: `0.1.0` released; `0.2.0` in development. The API may still change before `1.0.0`. +> Status: `0.2.0` released; `0.3.0` in development. The API may still change before `1.0.0`. ## Showcase diff --git a/assets/readme/manual-dark.pdf b/assets/readme/manual-dark.pdf index adb8189..a59ac74 100644 Binary files a/assets/readme/manual-dark.pdf and b/assets/readme/manual-dark.pdf differ diff --git a/assets/readme/manual.md b/assets/readme/manual.md index 2a2eaa0..60b15fe 100644 --- a/assets/readme/manual.md +++ b/assets/readme/manual.md @@ -45,6 +45,10 @@ The library is a single Maven dependency; the GraphCompose engine comes in trans ``` +> [!NOTE] +> Not yet on Maven Central (first release pending). Until then, build from source +> (`./mvnw install`) and depend on the snapshot, or consume a tagged version via JitPack. + Two **optional** companion artifacts unlock extras; add them only if you want them: | Artifact | What it adds | diff --git a/assets/readme/manual.pdf b/assets/readme/manual.pdf index 1b45580..8334fa9 100644 Binary files a/assets/readme/manual.pdf and b/assets/readme/manual.pdf differ diff --git a/cli/pom.xml b/cli/pom.xml index e874094..0a79c8a 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -30,7 +30,7 @@ UTF-8 0.3.0-SNAPSHOT 4.7.6 - 6.1.0 + 6.1.1 3.27.7 3.5.6 @@ -92,6 +92,24 @@ graph-compose-markdown-cli + + + + src/main/resources + true + + gcmd-cli.properties + + + + src/main/resources + false + + gcmd-cli.properties + + + diff --git a/cli/src/main/java/io/github/demchaav/markdown/cli/MarkdownCli.java b/cli/src/main/java/io/github/demchaav/markdown/cli/MarkdownCli.java index b28ef7a..1c34dfa 100644 --- a/cli/src/main/java/io/github/demchaav/markdown/cli/MarkdownCli.java +++ b/cli/src/main/java/io/github/demchaav/markdown/cli/MarkdownCli.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.util.Locale; import java.util.Optional; +import java.util.Properties; import java.util.concurrent.Callable; /** @@ -31,7 +32,7 @@ @Command( name = "gcmd", mixinStandardHelpOptions = true, - version = "graph-compose-markdown CLI 0.2.0-SNAPSHOT", + versionProvider = MarkdownCli.ModuleVersionProvider.class, sortOptions = false, description = "Render a Markdown file to a themed PDF (powered by graph-compose-markdown).") public final class MarkdownCli implements Callable { @@ -177,6 +178,24 @@ private static MarkdownTheme resolveTheme(String name) { } } + /** + * Supplies {@code --version} from the Maven-filtered {@code gcmd-cli.properties} resource so + * the reported version tracks the module {@code } automatically. Replaces a hard-coded + * string that silently drifted to a stale {@code 0.2.0-SNAPSHOT} at the v0.2.0 tag. + */ + static final class ModuleVersionProvider implements CommandLine.IVersionProvider { + @Override + public String[] getVersion() throws IOException { + Properties props = new Properties(); + try (var in = MarkdownCli.class.getResourceAsStream("/gcmd-cli.properties")) { + if (in != null) { + props.load(in); + } + } + return new String[] {"graph-compose-markdown CLI " + props.getProperty("version", "unknown")}; + } + } + /** Resolves {@code :shortcode:} to {@code /.png} on the filesystem. */ private static EmojiResolver fileEmojiResolver(Path dir) { return shortcode -> { diff --git a/cli/src/main/resources/gcmd-cli.properties b/cli/src/main/resources/gcmd-cli.properties new file mode 100644 index 0000000..a80a5f7 --- /dev/null +++ b/cli/src/main/resources/gcmd-cli.properties @@ -0,0 +1,4 @@ +# Maven-filtered at build time so the CLI's --version tracks the module version +# automatically and can never drift from the pom (a hard-coded string read a stale +# "0.2.0-SNAPSHOT" at the v0.2.0 tag). +version=${project.version} diff --git a/cli/src/test/java/io/github/demchaav/markdown/cli/MarkdownCliTest.java b/cli/src/test/java/io/github/demchaav/markdown/cli/MarkdownCliTest.java index 4b1b1e8..b49415e 100644 --- a/cli/src/test/java/io/github/demchaav/markdown/cli/MarkdownCliTest.java +++ b/cli/src/test/java/io/github/demchaav/markdown/cli/MarkdownCliTest.java @@ -76,10 +76,25 @@ void unknownThemeExitsWithCodeTwo() throws IOException { } @Test - void versionFlagExitsZero() { - CliResult result = run("--version"); + void versionFlagPrintsTheModuleVersion() { + // --version prints to stdout; capture it and assert it carries a real version number + // rather than a stale hard-coded literal (the string used to drift from the pom). + PrintStream originalOut = System.out; + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + System.setOut(new PrintStream(stdout, true, StandardCharsets.UTF_8)); + int exitCode; + try { + exitCode = MarkdownCli.newCommandLine().execute("--version"); + } finally { + System.setOut(originalOut); + } - assertThat(result.exitCode()).isZero(); + assertThat(exitCode).isZero(); + String out = stdout.toString(StandardCharsets.UTF_8); + assertThat(out).contains("graph-compose-markdown CLI"); + // A concrete version (filtered from the module ), never the literal placeholder. + assertThat(out).containsPattern("CLI \\d+\\.\\d+"); + assertThat(out).doesNotContain("${project.version}"); } @Test diff --git a/docs/custom-renderers.md b/docs/custom-renderers.md index 7f7f3fa..d031b27 100644 --- a/docs/custom-renderers.md +++ b/docs/custom-renderers.md @@ -178,3 +178,11 @@ MarkdownTheme book = MarkdownTheme.builder(DefaultMarkdownTheme.light()) The same seam accepts your own renderer for any node type — tables, code blocks, headings — while every other component is reused. + +A heading or TOC renderer needs the anchors the built-ins plan up front (so its links stay +navigable). Those are on `RenderContext`: `ctx.headingSlug(headingNode)` returns the planned, +document-unique slug for a heading — use it rather than `ctx.headingAnchor(title)`, which +allocates a *fresh* slug and would desync your anchor from what `[TOC]` and `[text](#slug)` +jump to — and `ctx.tocEntries()` returns the document's headings (level, text, slug) as public +`TocEntry` records for a custom `NodeRenderer` to lay out. `BookTocRenderer` is built on +exactly these. diff --git a/examples/README.md b/examples/README.md index 75cd3db..d23e8ea 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.1.0-SNAPSHOT` into your +This installs `io.github.demchaav:graph-compose-markdown:0.3.0-SNAPSHOT` into your local Maven repository, which these examples depend on. ## 2. Run an example diff --git a/src/main/java/io/github/demchaav/markdown/extension/CustomBlockParser.java b/src/main/java/io/github/demchaav/markdown/extension/CustomBlockParser.java index a84ee7f..038ad8a 100644 --- a/src/main/java/io/github/demchaav/markdown/extension/CustomBlockParser.java +++ b/src/main/java/io/github/demchaav/markdown/extension/CustomBlockParser.java @@ -105,7 +105,7 @@ private List parseBlocks(String text, Map footnot Matcher open = OPEN.matcher(line.strip()); if (isOpen(line) && open.matches()) { flushNormal(normal, result, footnoteNumbers); - String type = open.group(1).toLowerCase(); + String type = open.group(1).toLowerCase(java.util.Locale.ROOT); String variant = open.group(2); int depth = 1; diff --git a/src/main/java/io/github/demchaav/markdown/extension/DefaultImageResolver.java b/src/main/java/io/github/demchaav/markdown/extension/DefaultImageResolver.java index 54f2dfe..e0c4d96 100644 --- a/src/main/java/io/github/demchaav/markdown/extension/DefaultImageResolver.java +++ b/src/main/java/io/github/demchaav/markdown/extension/DefaultImageResolver.java @@ -76,7 +76,7 @@ public Optional resolve(String source) { if (source == null || source.isBlank()) { return Optional.empty(); } - String lower = source.toLowerCase(); + String lower = source.toLowerCase(java.util.Locale.ROOT); if (lower.startsWith("http://") || lower.startsWith("https://")) { return Optional.empty(); // remote loading is opt-in via a custom resolver } diff --git a/src/main/java/io/github/demchaav/markdown/mapper/FlexmarkAstMapper.java b/src/main/java/io/github/demchaav/markdown/mapper/FlexmarkAstMapper.java index b95bba3..69f11b2 100644 --- a/src/main/java/io/github/demchaav/markdown/mapper/FlexmarkAstMapper.java +++ b/src/main/java/io/github/demchaav/markdown/mapper/FlexmarkAstMapper.java @@ -246,7 +246,9 @@ public List footnoteDefinitions(com.vladsch.flexmark.uti private void assignFootnoteNumbers(Node node, Map numbers, int[] next) { for (Node child = node.getFirstChild(); child != null; child = child.getNext()) { - if (child instanceof Footnote footnote) { + if (child instanceof Footnote footnote && footnote.getFootnoteBlock() != null) { + // Only a DEFINED [^label] gets a number; an undefined one renders as literal text + // (below), so numbering it would leave a dangling superscript and a gap in the run. String label = footnoteLabel(footnote.getText()); if (!label.isEmpty() && !numbers.containsKey(label)) { numbers.put(label, next[0]++); @@ -343,8 +345,13 @@ private MarkdownNode mapTable(TableBlock table) { } } - // A GFM table is only valid with a header (which defines the columns). - return alignments.isEmpty() ? null : new TableNode(alignments, header, bodyRows); + // A GFM table is only valid with a header (which defines the columns). Rather than + // dropping a header-less table to null — which would silently swallow its whole body, + // invisible even to strict mode — surface its source like any other unmodelled block. + if (alignments.isEmpty()) { + return new UnsupportedBlockNode(table.getNodeName(), table.getChars().toString()); + } + return new TableNode(alignments, header, bodyRows); } private List mapRow(TableRow row) { @@ -452,7 +459,9 @@ private InlineNode mapInline(Node node) { } if (node instanceof Footnote footnote) { Integer number = footnoteNumbers.get().get(footnoteLabel(footnote.getText())); - return number != null ? new FootnoteRefRun(number) : null; + // An undefined [^label] was never numbered; render its literal source (CommonMark keeps + // the brackets) as plain text rather than a dangling superscript with no note to reach. + return number != null ? new FootnoteRefRun(number) : new TextRun(footnote.getChars().toString()); } if (node instanceof Emoji emoji) { return new EmojiRun(emoji.getText().toString()); @@ -489,7 +498,7 @@ private InlineNode mapInline(Node node) { } if (node instanceof HtmlInline inline) { String raw = inline.getChars().toString(); - String html = raw.trim().toLowerCase(); + String html = raw.trim().toLowerCase(java.util.Locale.ROOT); if (html.equals("
") || html.equals("
") || html.equals("
")) { return new LineBreakRun(true); } diff --git a/src/main/java/io/github/demchaav/markdown/parser/FlexmarkMarkdownParser.java b/src/main/java/io/github/demchaav/markdown/parser/FlexmarkMarkdownParser.java index 4529732..403b464 100644 --- a/src/main/java/io/github/demchaav/markdown/parser/FlexmarkMarkdownParser.java +++ b/src/main/java/io/github/demchaav/markdown/parser/FlexmarkMarkdownParser.java @@ -53,6 +53,17 @@ public FlexmarkMarkdownParser(boolean smartPunctuation) { extensions.add(TypographicExtension.create()); } options.set(Parser.EXTENSIONS, extensions); + // Pin the tables extension to GitHub-Flavored-Markdown semantics. Flexmark's defaults + // otherwise accept column spans (`| a || b |`), multiple header rows and trailing + // `[caption]` lines — none of which are GFM — and the mapper silently drops that extra + // structure, so a document could lose cells or a whole caption without warning. + options.set(TablesExtension.COLUMN_SPANS, false) + .set(TablesExtension.MIN_HEADER_ROWS, 1) + .set(TablesExtension.MAX_HEADER_ROWS, 1) + .set(TablesExtension.APPEND_MISSING_COLUMNS, true) + .set(TablesExtension.DISCARD_EXTRA_COLUMNS, true) + .set(TablesExtension.WITH_CAPTION, false) + .set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true); this.parser = Parser.builder(options).build(); } 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 91b5d4f..0a61dae 100644 --- a/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java +++ b/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java @@ -419,7 +419,7 @@ private static DocumentColor accentFor(String variant, AlertColors colors, Docum if (variant == null) { return fallback; } - switch (variant.toLowerCase()) { + switch (variant.toLowerCase(java.util.Locale.ROOT)) { case "warning": case "caution": return colors.calloutWarning(); diff --git a/src/main/java/io/github/demchaav/markdown/render/ListRenderer.java b/src/main/java/io/github/demchaav/markdown/render/ListRenderer.java index a3af749..636e431 100644 --- a/src/main/java/io/github/demchaav/markdown/render/ListRenderer.java +++ b/src/main/java/io/github/demchaav/markdown/render/ListRenderer.java @@ -81,9 +81,17 @@ private static void renderItem(ListItemNode item, SectionBuilder listSection, Re for (int i = start; i < content.size(); i++) { MarkdownNode child = content.get(i); if (child instanceof ListNode nested) { + // A nested list computes its own absolute indent from depth, so it is added + // straight into the item section (wrapping it would double-indent it). renderList(nested, itemSec, ctx, depth + 1); } else { - ctx.renderBlock(child, itemSec); + // A continuation block (second paragraph, code block, quote, …) bypasses the + // marker paragraph's margin, so wrap it in a section carrying the item's left + // indent — otherwise it renders flush against the page margin, left of the marker. + itemSec.addSection(contSec -> { + contSec.margin(new DocumentInsets(0, 0, 0, indent)); + ctx.renderBlock(child, contSec); + }); } } }); diff --git a/src/main/java/io/github/demchaav/markdown/render/RenderContext.java b/src/main/java/io/github/demchaav/markdown/render/RenderContext.java index 860bf61..4da6a52 100644 --- a/src/main/java/io/github/demchaav/markdown/render/RenderContext.java +++ b/src/main/java/io/github/demchaav/markdown/render/RenderContext.java @@ -192,13 +192,29 @@ public boolean hasBookmarkableHeading() { return state.tocEntries().stream().anyMatch(entry -> !entry.text().isEmpty()); } - /** @return the planned slug anchor for a heading, or {@code null} if it was not planned */ - String headingSlug(HeadingNode node) { + /** + * Returns the anchor slug that {@link #planHeadings} assigned to this heading node — the same + * slug the built-in heading renderer declares and that {@code [TOC]} / {@code [text](#slug)} + * links resolve to. A custom {@code NodeRenderer} should call this (rather than + * {@link #headingAnchor(String)}, which allocates a fresh slug and would desync the + * anchor from the planned links) to stay navigable. + * + * @param node the heading node being rendered + * @return the planned slug, or {@code null} if the heading was not part of the planned document + */ + public String headingSlug(HeadingNode node) { return state.slugFor(node); } - /** @return the document's headings as ordered table-of-contents entries */ - List tocEntries() { + /** + * Returns the document's headings as ordered table-of-contents entries, meaningful after + * {@link #planHeadings}. Exposed so a custom {@code NodeRenderer} (for example a + * page-numbered book contents) can build its own contents from the same planned entries the + * default {@code [TOC]} renderer uses. + * + * @return the planned TOC entries in document order (empty before planning) + */ + public List tocEntries() { return state.tocEntries(); } diff --git a/src/main/java/io/github/demchaav/markdown/render/RenderState.java b/src/main/java/io/github/demchaav/markdown/render/RenderState.java index 02b2e5c..4f0adba 100644 --- a/src/main/java/io/github/demchaav/markdown/render/RenderState.java +++ b/src/main/java/io/github/demchaav/markdown/render/RenderState.java @@ -30,6 +30,9 @@ final class RenderState { /** Base slug -> number of times it has been assigned, for GitHub-style de-duplication. */ private final Map slugCounts = new HashMap<>(); + /** Every slug already handed out, so a generated {@code -N} can never collide with a later heading. */ + private final Set assignedSlugs = new HashSet<>(); + /** Footnote numbers whose {@code fnref-N} back-anchor has already been placed. */ private final Set placedFootnoteRefs = new HashSet<>(); @@ -84,13 +87,16 @@ String headingAnchor(String title) { base = "section"; } Integer seen = slugCounts.get(base); - if (seen == null) { - slugCounts.put(base, 0); - return base; + int next = seen == null ? 0 : seen + 1; + String candidate = next == 0 ? base : base + "-" + next; + // Bump past any slug already taken — e.g. headings "Foo", "Foo", "Foo 1" would all want + // "foo-1"; keep incrementing so every heading still gets a document-unique anchor. + while (!assignedSlugs.add(candidate)) { + next++; + candidate = base + "-" + next; } - int next = seen + 1; slugCounts.put(base, next); - return base + "-" + next; + return candidate; } /** diff --git a/src/main/java/io/github/demchaav/markdown/render/TocEntry.java b/src/main/java/io/github/demchaav/markdown/render/TocEntry.java index d908d5b..22d6e83 100644 --- a/src/main/java/io/github/demchaav/markdown/render/TocEntry.java +++ b/src/main/java/io/github/demchaav/markdown/render/TocEntry.java @@ -8,5 +8,5 @@ * @param text the heading's plain text (inline markup stripped) * @param slug the heading's anchor slug */ -record TocEntry(int level, String text, String slug) { +public record TocEntry(int level, String text, String slug) { } diff --git a/src/main/java/io/github/demchaav/markdown/theme/DefaultMarkdownTheme.java b/src/main/java/io/github/demchaav/markdown/theme/DefaultMarkdownTheme.java index baff561..28255f7 100644 --- a/src/main/java/io/github/demchaav/markdown/theme/DefaultMarkdownTheme.java +++ b/src/main/java/io/github/demchaav/markdown/theme/DefaultMarkdownTheme.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import io.github.demchaav.markdown.render.StandardPack; +import io.github.demchaav.markdown.theme.tokens.AlertColors; import io.github.demchaav.markdown.theme.tokens.ColorTokens; import io.github.demchaav.markdown.theme.tokens.FontFamily; import io.github.demchaav.markdown.theme.tokens.MarkdownTokens; @@ -129,6 +130,7 @@ private static MarkdownTokens darkTokens() { DocumentColor.rgb(51, 65, 85), // rule DocumentColor.rgb(96, 165, 250), // accent DocumentColor.rgb(15, 23, 42)); // surface (dark page) - return new MarkdownTokens(colors, typography(), spacing(), shape(), page(), SyntaxColors.defaultDark()); + return new MarkdownTokens(colors, typography(), spacing(), shape(), page(), SyntaxColors.defaultDark()) + .withAlertColors(AlertColors.defaultDark()); } } diff --git a/src/main/java/io/github/demchaav/markdown/theme/packs/GitHubTheme.java b/src/main/java/io/github/demchaav/markdown/theme/packs/GitHubTheme.java index 8c55fe9..7604548 100644 --- a/src/main/java/io/github/demchaav/markdown/theme/packs/GitHubTheme.java +++ b/src/main/java/io/github/demchaav/markdown/theme/packs/GitHubTheme.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.style.DocumentColor; import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; import io.github.demchaav.markdown.theme.MarkdownTheme; +import io.github.demchaav.markdown.theme.tokens.AlertColors; import io.github.demchaav.markdown.theme.tokens.ColorTokens; import io.github.demchaav.markdown.theme.tokens.FontFamily; import io.github.demchaav.markdown.theme.tokens.MarkdownTokens; @@ -71,6 +72,7 @@ public static MarkdownTheme dark() { DocumentColor.rgb(13, 17, 23)); // surface return DefaultMarkdownTheme.of(new MarkdownTokens( colors, typography(), PackSupport.spacing(10.0, 18.0), PackSupport.shape(6.0, 1.0), - PackSupport.a4Page(56.0), SyntaxColors.defaultDark())); + PackSupport.a4Page(56.0), SyntaxColors.defaultDark()) + .withAlertColors(AlertColors.defaultDark())); } } diff --git a/src/main/java/io/github/demchaav/markdown/theme/tokens/AlertColors.java b/src/main/java/io/github/demchaav/markdown/theme/tokens/AlertColors.java index 1486545..6ee8021 100644 --- a/src/main/java/io/github/demchaav/markdown/theme/tokens/AlertColors.java +++ b/src/main/java/io/github/demchaav/markdown/theme/tokens/AlertColors.java @@ -68,4 +68,24 @@ public static AlertColors defaultPalette() { DocumentColor.rgb(220, 38, 38), // callout error / danger DocumentColor.rgb(22, 163, 74)); // callout success / tip } + + /** + * A palette tuned for dark surfaces. The light {@link #defaultPalette()} accents — the amber + * warning especially — fall below a readable contrast on a near-black page, which is the very + * case this token group exists to fix. These mirror GitHub's dark-mode alert accents. + * + * @return the dark alert/callout palette + */ + public static AlertColors defaultDark() { + return new AlertColors( + DocumentColor.rgb(68, 147, 248), // note — blue + DocumentColor.rgb(63, 185, 80), // tip — green + DocumentColor.rgb(171, 125, 248), // important — purple + DocumentColor.rgb(210, 153, 34), // warning — amber + DocumentColor.rgb(248, 81, 73), // caution — red + DocumentColor.rgb(88, 166, 255), // callout info / note + DocumentColor.rgb(210, 153, 34), // callout warning / caution + DocumentColor.rgb(248, 81, 73), // callout error / danger + DocumentColor.rgb(63, 185, 80)); // callout success / tip + } } diff --git a/src/test/java/io/github/demchaav/markdown/HeadingSlugSeamTest.java b/src/test/java/io/github/demchaav/markdown/HeadingSlugSeamTest.java new file mode 100644 index 0000000..a4339ab --- /dev/null +++ b/src/test/java/io/github/demchaav/markdown/HeadingSlugSeamTest.java @@ -0,0 +1,71 @@ +package io.github.demchaav.markdown; + +import io.github.demchaav.markdown.composer.MarkdownComposer; +import io.github.demchaav.markdown.model.HeadingNode; +import io.github.demchaav.markdown.render.TocEntry; +import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; +import io.github.demchaav.markdown.theme.MarkdownTheme; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The custom-renderer seam documented in {@code docs/custom-renderers.md} must be reachable for + * headings and the TOC from outside the render package. A third-party {@code NodeRenderer} + * needs the planned slug (so its anchor matches what {@code [TOC]} / {@code [text](#slug)} + * jump to), and a {@code NodeRenderer} needs the planned entries — hence + * {@link io.github.demchaav.markdown.render.RenderContext#headingSlug} / + * {@link io.github.demchaav.markdown.render.RenderContext#tocEntries} and {@link TocEntry} are public. + * Living outside {@code io.github.demchaav.markdown.render}, this test would not compile if the seam + * were still package-private. + */ +class HeadingSlugSeamTest { + + @Test + void aCustomHeadingRendererSeesThePlannedSlugAndTocEntries() { + List slugs = new ArrayList<>(); + List toc = new ArrayList<>(); + + MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light()) + .renderer(HeadingNode.class, (node, host, ctx) -> { + slugs.add(ctx.headingSlug(node)); + if (toc.isEmpty()) { + toc.addAll(ctx.tocEntries()); + } + host.addParagraph(p -> p + .rich(ctx.toRich(node.content(), ctx.headingInline(node.level()))) + .anchor(ctx.headingSlug(node))); + }) + .build(); + + MarkdownComposer.builder().theme(theme).build() + .render("# Hello World\n\n## Hello World\n").toPdfBytes(); + + // The planned, document-unique GitHub slugs — the same ones [TOC] links to. + assertThat(slugs).containsExactly("hello-world", "hello-world-1"); + assertThat(toc).extracting(TocEntry::slug).containsExactly("hello-world", "hello-world-1"); + assertThat(toc).extracting(TocEntry::text).containsExactly("Hello World", "Hello World"); + } + + @Test + void generatedDuplicateSlugsNeverCollide() { + List slugs = new ArrayList<>(); + + MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light()) + .renderer(HeadingNode.class, (node, host, ctx) -> { + slugs.add(ctx.headingSlug(node)); + host.addParagraph(p -> p.rich(ctx.toRich(node.content(), ctx.headingInline(node.level())))); + }) + .build(); + + // "Foo", "Foo", "Foo 1" all want the slug "foo-1"; every heading must still get a unique one. + MarkdownComposer.builder().theme(theme).build() + .render("# Foo\n\n# Foo\n\n# Foo 1\n").toPdfBytes(); + + assertThat(slugs).doesNotContainNull(); + assertThat(slugs).as("every heading gets a document-unique anchor").doesNotHaveDuplicates(); + } +} diff --git a/src/test/java/io/github/demchaav/markdown/ListContinuationIndentTest.java b/src/test/java/io/github/demchaav/markdown/ListContinuationIndentTest.java new file mode 100644 index 0000000..a47d6ab --- /dev/null +++ b/src/test/java/io/github/demchaav/markdown/ListContinuationIndentTest.java @@ -0,0 +1,66 @@ +package io.github.demchaav.markdown; + +import io.github.demchaav.markdown.composer.MarkdownComposer; +import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A list item's continuation block (a second paragraph, a code block, …) must keep the item's + * left indent, not render flush against the page margin — the regression this pins. It renders a + * plain top-level paragraph followed by a loose list item whose second paragraph is a continuation, + * then reads the glyph x-positions: the continuation must sit measurably further right than the + * top-level paragraph (i.e. it is indented under the item, not hanging at the margin). + */ +class ListContinuationIndentTest { + + @Test + void listItemContinuationParagraphKeepsTheItemIndent() throws IOException { + byte[] pdf = MarkdownComposer.create(DefaultMarkdownTheme.light()).render(""" + MARGINWORD sits at the page margin. + + - ITEMWORD is the item's first paragraph + + CONTINUATIONWORD is a second paragraph of the same item + """).toPdfBytes(); + + double marginX = firstGlyphX(pdf, "MARGINWORD"); + double continuationX = firstGlyphX(pdf, "CONTINUATIONWORD"); + + assertThat(marginX).as("plain paragraph x").isNotNaN(); + assertThat(continuationX).as("continuation paragraph x").isNotNaN(); + // Before the fix the continuation rendered at the page margin (same x as the plain + // paragraph, actually left of the item marker). It must now be indented under the item. + assertThat(continuationX).as("continuation is indented past the page margin") + .isGreaterThan(marginX + 8.0); + } + + /** The x-coordinate of the first glyph of {@code needle} on page 1, or NaN if not found. */ + private static double firstGlyphX(byte[] pdf, String needle) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + double[] x = {Double.NaN}; + PDFTextStripper stripper = new PDFTextStripper() { + @Override + protected void writeString(String text, List positions) throws IOException { + int at = text.indexOf(needle); + if (Double.isNaN(x[0]) && at >= 0 && at < positions.size()) { + x[0] = positions.get(at).getXDirAdj(); + } + super.writeString(text, positions); + } + }; + stripper.setStartPage(1); + stripper.setEndPage(1); + stripper.getText(document); + return x[0]; + } + } +} diff --git a/src/test/java/io/github/demchaav/markdown/ManualTest.java b/src/test/java/io/github/demchaav/markdown/ManualTest.java index ef9070a..4d37a82 100644 --- a/src/test/java/io/github/demchaav/markdown/ManualTest.java +++ b/src/test/java/io/github/demchaav/markdown/ManualTest.java @@ -12,6 +12,7 @@ import org.apache.pdfbox.pdmodel.PageMode; import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; import java.nio.file.Path; @@ -51,6 +52,19 @@ private static MarkdownComposer manualComposer(MarkdownTheme base) { .build(); } + /** + * Regenerates the committed {@code manual.pdf} / {@code manual-dark.pdf} from {@code manual.md} + * using the exact composers the other tests assert against — the only supported way to refresh + * the checked-in PDFs so they never drift from the source. Opt-in (it writes into the repo): + *
./mvnw test -Dtest=ManualTest#regeneratesTheCommittedManualPdfs -Dgcmd.regenerateManual=true -pl .
+ */ + @Test + @EnabledIfSystemProperty(named = "gcmd.regenerateManual", matches = "true") + void regeneratesTheCommittedManualPdfs() throws Exception { + manualComposer().renderFile(BASE.resolve("manual.md")).writePdf(BASE.resolve("manual.pdf")); + darkManualComposer().renderFile(BASE.resolve("manual.md")).writePdf(BASE.resolve("manual-dark.pdf")); + } + @Test void theManualRendersItsWholeFeatureSet() throws Exception { byte[] pdf = manualComposer().renderFile(BASE.resolve("manual.md")).toPdfBytes(); diff --git a/src/test/java/io/github/demchaav/markdown/TableGfmParityTest.java b/src/test/java/io/github/demchaav/markdown/TableGfmParityTest.java new file mode 100644 index 0000000..b2f65f0 --- /dev/null +++ b/src/test/java/io/github/demchaav/markdown/TableGfmParityTest.java @@ -0,0 +1,60 @@ +package io.github.demchaav.markdown; + +import io.github.demchaav.markdown.composer.MarkdownComposer; +import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The tables extension is pinned to GitHub-Flavored-Markdown semantics. Flexmark's defaults + * accept a trailing {@code [caption]} line (and column spans, and extra header rows) that the + * mapper does not model and would silently drop; under GFM parity that line is just ordinary + * text and survives. This pins the caption case, which is the one that swallowed user content. + */ +class TableGfmParityTest { + + private static String text(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document); + } + } + + private static byte[] render(String markdown) { + return MarkdownComposer.create(DefaultMarkdownTheme.light()).render(markdown).toPdfBytes(); + } + + @Test + void aTrailingCaptionLineIsKeptAsTextNotSwallowed() throws IOException { + // With flexmark's default WITH_CAPTION, the "[CAPTIONWORD]" line is parsed as a table + // caption the mapper drops. Under GFM parity it is plain text and must reach the page. + byte[] pdf = render(""" + | A | B | + |---|---| + | 1 | 2 | + [CAPTIONWORD after the table] + """); + + String text = text(pdf); + assertThat(text).contains("A").contains("B").contains("1").contains("2"); + assertThat(text).as("the trailing caption line is not silently swallowed") + .contains("CAPTIONWORD"); + } + + @Test + void anOrdinaryTableStillRenders() throws IOException { + byte[] pdf = render(""" + | Feature | Status | + |---------|--------| + | Tables | Done | + """); + + assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + assertThat(text(pdf)).contains("Feature").contains("Status").contains("Tables").contains("Done"); + } +} diff --git a/src/test/java/io/github/demchaav/markdown/UndefinedFootnoteTest.java b/src/test/java/io/github/demchaav/markdown/UndefinedFootnoteTest.java new file mode 100644 index 0000000..da67a35 --- /dev/null +++ b/src/test/java/io/github/demchaav/markdown/UndefinedFootnoteTest.java @@ -0,0 +1,41 @@ +package io.github.demchaav.markdown; + +import io.github.demchaav.markdown.composer.MarkdownComposer; +import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A footnote reference with no matching definition ({@code [^missing]}) must render as its literal + * source — the CommonMark behaviour — not consume a number and leave a dangling superscript with a + * gap in the run. A defined footnote alongside it still numbers and renders normally. + */ +class UndefinedFootnoteTest { + + private static String text(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document); + } + } + + @Test + void undefinedFootnoteRendersAsLiteralTextAndDefinedOneStillWorks() throws IOException { + byte[] pdf = MarkdownComposer.create(DefaultMarkdownTheme.light()).render(""" + A real note[^a] next to a dangling one[^missing]. + + [^a]: The defined note body. + """).toPdfBytes(); + + String text = text(pdf); + // The defined footnote's body is rendered in the Notes section. + assertThat(text).contains("The defined note body"); + // The undefined reference survives as its literal source rather than a numbered superscript. + assertThat(text).contains("[^missing]"); + } +}