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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ and the project follows [Semantic Versioning](https://semver.org/).
link — a heading's single anchor slot already carries its navigation slug.

### Changed
- **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
`smartPunctuation` flag — it is the GitHub-parity behaviour for HTML entities — and is the one
default-mode output change in this release's punctuation work.
- **Renderer classes reorganised (source-compatible for most users).** The four largest built-in
renderers moved from nested classes to top-level classes in the same package:
`BuiltinRenderers.ListRenderer` → `ListRenderer`, and likewise `TableRenderer`,
Expand All @@ -28,6 +33,20 @@ and the project follows [Semantic Versioning](https://semver.org/).
needs the import updated. `BuiltinRenderers` drops from ~750 to ~440 lines.

### Added
- **Opt-in smart punctuation — `MarkdownComposer.builder().smartPunctuation(true)`.** Typographic
replacements at parse time: straight quotes become curly quotes (nested formatting inside the
quotes survives), apostrophes curl (`don't` → `don’t`), `--` becomes an en-dash, `---` an
em-dash, `...` (and spaced `. . .`) an ellipsis, and `<<text>>` becomes `«text»` guillemets.
Off by default — GitHub does not smart-quote — and code spans / code blocks always stay
verbatim. Backed by `flexmark-ext-typographic`.
- **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).
- **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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ MarkdownTheme numbered = MarkdownTheme.builder(t)
.build();
```

Prefer typographic output? `MarkdownComposer.builder().smartPunctuation(true)` turns
`--` / `---` / `...` and straight quotes into – — … and “curly” quotes (code stays
verbatim; off by default, matching GitHub).

## Command-line (CLI)

A standalone `cli/` module renders Markdown to PDF from the shell — no Java code
Expand Down
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@
<artifactId>flexmark-ext-yaml-front-matter</artifactId>
<version>${flexmark.version}</version>
</dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-ext-typographic</artifactId>
<version>${flexmark.version}</version>
</dependency>

<!-- Test dependencies -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public final class MarkdownComposer {
private final MarkdownTheme theme;

private MarkdownComposer(Builder builder) {
this.parser = new FlexmarkMarkdownParser();
this.parser = new FlexmarkMarkdownParser(builder.smartPunctuation);
this.mapper = new FlexmarkAstMapper();
this.customBlockParser = new CustomBlockParser(parser, mapper);
this.customBlocksEnabled = builder.customBlocks;
Expand Down Expand Up @@ -209,6 +209,7 @@ public static final class Builder {
private boolean customBlocks = true;
private boolean strict = false;
private boolean openOutline = true;
private boolean smartPunctuation = false;

private Builder() {
}
Expand Down Expand Up @@ -250,6 +251,21 @@ public Builder strictMode(boolean enabled) {
return this;
}

/**
* Enables typographic smart punctuation (default off, matching GitHub's rendering):
* straight quotes become curly quotes, apostrophes curl ({@code don't} → {@code don’t}),
* {@code --} becomes an en-dash, {@code ---} an em-dash, {@code ...} (and spaced
* {@code . . .}) an ellipsis, and {@code <<text>>} becomes {@code «text»} guillemets.
* Code spans and code blocks always stay verbatim.
*
* @param enabled whether to apply typographic replacements
* @return this builder
*/
public Builder smartPunctuation(boolean enabled) {
this.smartPunctuation = enabled;
return this;
}

/**
* Controls whether a rendered PDF asks the viewer to open its bookmark/outline panel
* (default on). The preference is only written when the document actually has at least
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.vladsch.flexmark.ext.footnotes.FootnoteBlock;
import com.vladsch.flexmark.ext.gfm.strikethrough.Strikethrough;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListItem;
import com.vladsch.flexmark.ext.typographic.TypographicQuotes;
import com.vladsch.flexmark.ext.typographic.TypographicSmarts;
import com.vladsch.flexmark.ext.tables.*;
import com.vladsch.flexmark.ext.yaml.front.matter.AbstractYamlFrontMatterVisitor;
import com.vladsch.flexmark.ext.yaml.front.matter.YamlFrontMatterBlock;
Expand Down Expand Up @@ -38,7 +40,10 @@ public final class FlexmarkAstMapper {
Map.entry("amp", "&"), Map.entry("lt", "<"), Map.entry("gt", ">"),
Map.entry("quot", "\""), Map.entry("apos", "'"), Map.entry("nbsp", " "),
Map.entry("copy", "©"), Map.entry("reg", "®"), Map.entry("trade", "™"),
Map.entry("mdash", "—"), Map.entry("ndash", "–"), Map.entry("hellip", "…"));
Map.entry("mdash", "—"), Map.entry("ndash", "–"), Map.entry("hellip", "…"),
Map.entry("lsquo", "‘"), Map.entry("rsquo", "’"),
Map.entry("ldquo", "“"), Map.entry("rdquo", "”"),
Map.entry("laquo", "«"), Map.entry("raquo", "»"));

// Footnote label -> number, assigned by first-reference order, scoped to the current
// mapping call. Flexmark does not populate footnote ordinals at parse time, and the
Expand Down Expand Up @@ -141,6 +146,11 @@ private static String normalizeCodeText(String code) {
return normalized;
}

/** Decodes a typographic mark carried as an HTML entity; {@code null} (hand-built node) → empty. */
private static String decodeTypographic(String entity) {
return entity == null ? "" : decodeEntity(entity);
}

private static String decodeEntity(String entity) {
if (!entity.startsWith("&") || !entity.endsWith(";") || entity.length() < 3) {
return entity;
Expand Down Expand Up @@ -387,6 +397,13 @@ private List<InlineNode> mapInlines(Node parent) {
// A transparent wrapper Flexmark inserts (e.g. for autolinking) — flatten it,
// so the Link/Text nodes inside are mapped rather than swallowed whole.
result.addAll(mapInlines(child));
} else if (child instanceof TypographicQuotes quotes) {
// Smart punctuation (opt-in): curly quote marks around the mapped inner
// inlines. Flexmark carries the marks as HTML entity names; reuse the decoder.
// Null marks (possible on hand-built nodes fed via render(Document)) emit nothing.
result.add(new TextRun(decodeTypographic(quotes.getTypographicOpening())));
result.addAll(mapInlines(quotes));
result.add(new TextRun(decodeTypographic(quotes.getTypographicClosing())));
} else {
InlineNode mapped = mapInline(child);
if (mapped != null) {
Expand Down Expand Up @@ -465,6 +482,11 @@ private InlineNode mapInline(Node node) {
if (node instanceof HtmlEntity entity) {
return new TextRun(decodeEntity(entity.getChars().toString()));
}
if (node instanceof TypographicSmarts smarts) {
// Smart punctuation (opt-in): -- / --- / ... as en/em-dash and ellipsis. Flexmark
// carries the replacement as an HTML entity name; reuse the decoder.
return new TextRun(decodeTypographic(smarts.getTypographicText()));
}
if (node instanceof HtmlInline inline) {
String raw = inline.getChars().toString();
String html = raw.trim().toLowerCase();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.ext.typographic.TypographicExtension;
import com.vladsch.flexmark.ext.yaml.front.matter.YamlFrontMatterExtension;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Document;
import com.vladsch.flexmark.util.data.MutableDataSet;
import com.vladsch.flexmark.util.misc.Extension;

import java.util.ArrayList;
import java.util.List;

/**
Expand All @@ -19,21 +22,37 @@
* <p>This is the only place in the library that touches Flexmark. The configured
* {@link Parser} enables CommonMark plus the GFM extensions for tables, task lists,
* strikethrough and footnotes, and the extensions for emoji shortcodes, autolinking
* and YAML front matter. A single parser instance is reused across calls; Flexmark's
* {@code parse} is safe to call concurrently once the parser is built.</p>
* and YAML front matter — plus, optionally, typographic smart punctuation. A single
* parser instance is reused across calls; Flexmark's {@code parse} is safe to call
* concurrently once the parser is built.</p>
*/
public final class FlexmarkMarkdownParser {

private final Parser parser;

/** Creates a parser for CommonMark plus the GFM, emoji, autolink and front-matter extensions. */
public FlexmarkMarkdownParser() {
this(false);
}

/**
* Creates a parser, optionally with typographic smart punctuation.
*
* @param smartPunctuation when {@code true}, straight quotes become curly quotes,
* {@code --} an en-dash, {@code ---} an em-dash and {@code ...}
* an ellipsis (code spans and code blocks stay verbatim)
*/
public FlexmarkMarkdownParser(boolean smartPunctuation) {
MutableDataSet options = new MutableDataSet();
options.set(Parser.EXTENSIONS, List.of(
List<Extension> extensions = new ArrayList<>(List.of(
StrikethroughExtension.create(), TablesExtension.create(),
TaskListExtension.create(), FootnoteExtension.create(),
EmojiExtension.create(), AutolinkExtension.create(),
YamlFrontMatterExtension.create()));
if (smartPunctuation) {
extensions.add(TypographicExtension.create());
}
options.set(Parser.EXTENSIONS, extensions);
this.parser = Parser.builder(options).build();
}

Expand Down
117 changes: 117 additions & 0 deletions src/test/java/io/github/demchaav/markdown/SmartPunctuationTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package io.github.demchaav.markdown;

import io.github.demchaav.markdown.composer.MarkdownComposer;
import io.github.demchaav.markdown.model.ParagraphNode;
import io.github.demchaav.markdown.model.inline.StrongRun;
import io.github.demchaav.markdown.model.inline.TextRun;
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 static org.assertj.core.api.Assertions.assertThat;

/**
* Opt-in typographic smart punctuation ({@code builder().smartPunctuation(true)}): dashes,
* ellipsis and curly quotes. Off by default (matching GitHub), and code stays verbatim.
*/
class SmartPunctuationTest {

private static String text(MarkdownComposer composer, String md) throws Exception {
try (PDDocument doc = Loader.loadPDF(composer.render(md).toPdfBytes())) {
return new PDFTextStripper().getText(doc);
}
}

@Test
void smartPunctuationReplacesDashesEllipsisAndQuotes() throws Exception {
MarkdownComposer composer = MarkdownComposer.builder().smartPunctuation(true).build();

String out = text(composer, "Pages 3--7 --- \"quoted\" and 'single', wait...");

assertThat(out).contains("–"); // en-dash from --
assertThat(out).contains("—"); // em-dash from ---
assertThat(out).contains("…"); // ellipsis from ...
assertThat(out).contains("“").contains("”"); // curly double quotes
assertThat(out).contains("‘").contains("’"); // curly single quotes
assertThat(out).doesNotContain("--").doesNotContain("...");
}

@Test
void offByDefaultKeepsLiteralPunctuation() throws Exception {
MarkdownComposer composer = MarkdownComposer.create(DefaultMarkdownTheme.light());

String out = text(composer, "Pages 3--7 \"quoted\" wait...");

assertThat(out).contains("3--7").contains("\"quoted\"").contains("wait...");
assertThat(out).doesNotContain("–").doesNotContain("…");
}

@Test
void codeStaysVerbatimEvenWithSmartPunctuationOn() throws Exception {
MarkdownComposer composer = MarkdownComposer.builder().smartPunctuation(true).build();

String out = text(composer, "Run `a --flag \"x\"` and:\n\n```\nb --opt 'y'...\n```\n");

assertThat(out).contains("--flag").contains("--opt");
assertThat(out).contains("'y'...");
assertThat(out).doesNotContain("–").doesNotContain("—");
}

@Test
void formattingInsideSmartQuotesSurvives() throws Exception {
MarkdownComposer composer = MarkdownComposer.builder().smartPunctuation(true).build();

String out = text(composer, "\"a **bold** word\"");

// The quotes wrap mapped children (bold still renders); marks are curly.
assertThat(out).contains("“").contains("bold").contains("”");
}

@Test
void apostrophesCurlAndGuillemetsConvert() throws Exception {
// The unmatched-single-quote (apostrophe) path is distinct from paired quotes,
// and <<text>> converts to guillemets.
MarkdownComposer composer = MarkdownComposer.builder().smartPunctuation(true).build();

String out = text(composer, "It don't matter <<much>> here");

assertThat(out).contains("don’t").contains("«much»");
assertThat(out).doesNotContain("&"); // no entity text ever leaks into the PDF
}

@Test
void replacementsApplyInsideCustomBlocksToo() throws Exception {
// The ::: segmented parse must use the same flag-configured parser instance.
MarkdownComposer composer = MarkdownComposer.builder().smartPunctuation(true).build();

String out = text(composer, """
:::note
range 3--7 wait...
:::

outside 1--2
""");

assertThat(out).contains("3–7").contains("…").contains("1–2");
assertThat(out).doesNotContain("--");
}

@Test
void boldInsideSmartQuotesSurvivesAtTheModelLevel() {
// Model-level proof (extraction can't distinguish bold): TextRun(curly-open) +
// StrongRun + ... + TextRun(curly-close).
MarkdownComposer composer = MarkdownComposer.builder().smartPunctuation(true).build();

var blocks = composer.render("\"a **bold** word\"").document().blocks();
var paragraph = (ParagraphNode) blocks.get(0);
var runs = paragraph.content();

assertThat(runs.get(0)).isInstanceOfSatisfying(TextRun.class,
r -> assertThat(r.text()).isEqualTo("“"));
assertThat(runs).anyMatch(r -> r instanceof StrongRun);
assertThat(runs.get(runs.size() - 1)).isInstanceOfSatisfying(TextRun.class,
r -> assertThat(r.text()).isEqualTo("”"));
}
}