Skip to content

Commit 30088b9

Browse files
authored
Preserve medial gaps in ::: | line blocks (#244)
LineBlockDivExtension preserved only leading indentation; medial and trailing runs of two or more spaces were collapsed by normal inline parsing. That dropped the alignment gaps verse relies on - the caesura of Old English verse, aligned columns in an address - exactly the inconsistency raised on djot#29. Now preserve every leading run plus any inner or trailing run of two or more columns as non-breaking spaces; a single inner space stays an ordinary, collapsible space so long lines can still wrap. Tabs expand to four-column stops. Renders as a non-breaking space in every format:   (HTML), U+00A0 (Markdown), ordinary space (plain text / ANSI).
1 parent 6818161 commit 30088b9

4 files changed

Lines changed: 156 additions & 31 deletions

File tree

docs/extensions/index.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,15 +609,33 @@ This follows the approach discussed in [djot issue #286](https://github.com/jgm/
609609

610610
Adds a fenced line block written as a `:::` div whose only class token is a pipe: `::: |`. It produces the same `line-block` div as the [`|`-prefixed form](/guide/syntax#line-blocks), but without prefixing every line - convenient for verse, addresses, lyrics, and signature blocks where each line would otherwise need a leading `|`.
611611

612-
Inside the fence, each soft line break becomes a hard break (`<br>`), leading whitespace is preserved, and a blank line separates stanzas (each becomes its own paragraph). Inline djot (emphasis, links, ...) still parses normally.
612+
Inside the fence, each soft line break becomes a hard break (`<br>`), significant whitespace is preserved, and a blank line separates stanzas (each becomes its own paragraph). Inline djot (emphasis, links, ...) still parses normally.
613613

614614
```php
615615
use Djot\Extension\LineBlockDivExtension;
616616

617617
$converter->addExtension(new LineBlockDivExtension());
618618
```
619619

620-
Leading whitespace on each line is preserved as a non-breaking space, so the indentation survives without any CSS: `&nbsp;` in HTML, a real non-breaking space (`U+00A0`) in Markdown - which keeps it through a round-trip re-render and never trips Markdown's indented-code-block rule - and an ordinary space in the plain-text and ANSI renderers. Tabs expand to four-column stops.
620+
Significant whitespace on each line is preserved as a non-breaking space, so the shape survives without any CSS: `&nbsp;` in HTML, a real non-breaking space (`U+00A0`) in Markdown - which keeps it through a round-trip re-render and never trips Markdown's indented-code-block rule - and an ordinary space in the plain-text and ANSI renderers. Tabs expand to four-column stops.
621+
622+
Significant means: all **leading** indentation, plus any **medial or trailing** run of two or more columns - the gaps used for inline alignment, such as the caesura in Old English verse or the columns of an address. A lone space between words stays an ordinary, collapsible space, so a long line can still wrap. This follows Pandoc's line-block rule that every space the author writes is meaningful, and addresses the [medial-gap point raised on djot#29](https://github.com/jgm/djot/issues/29).
623+
624+
**Input:**
625+
626+
```djot
627+
::: |
628+
Hwaet! We Gardena in geardagum
629+
theodcyninga thrym gefrunon
630+
:::
631+
```
632+
633+
```html
634+
<div class="line-block">
635+
<p>Hwaet! We Gardena&nbsp;&nbsp;&nbsp;&nbsp;in geardagum<br>
636+
theodcyninga&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;thrym gefrunon</p>
637+
</div>
638+
```
621639

622640
**Input:**
623641

docs/guide/syntax.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1277,7 +1277,7 @@ And so are you.</p>
12771277
</OutputTabs>
12781278

12791279
::: tip Fenced alternative
1280-
Prefer not to prefix every line? The [`LineBlockDivExtension`](/extensions/#lineblockdivextension) adds a fenced form, `::: |`, that produces the same `line-block` div without the per-line `|`. Leading whitespace is preserved and a blank line separates stanzas.
1280+
Prefer not to prefix every line? The [`LineBlockDivExtension`](/extensions/#lineblockdivextension) adds a fenced form, `::: |`, that produces the same `line-block` div without the per-line `|`. Leading indentation and medial alignment gaps are preserved, and a blank line separates stanzas.
12811281
:::
12821282

12831283
### Block Attributes

src/Extension/LineBlockDivExtension.php

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Djot\Node\Node;
1313
use Djot\Parser\Block\FencedBlockParser;
1414
use Djot\Parser\BlockParser;
15+
use Djot\Parser\InlineParser;
1516

1617
/**
1718
* Adds a fenced line-block div: `::: |`.
@@ -198,8 +199,8 @@ protected function splitStanzas(array $innerLines): array
198199
}
199200

200201
/**
201-
* Build one stanza paragraph: each line is inline-parsed and joined by a
202-
* hard break, so single newlines render as `<br>`.
202+
* Build one stanza paragraph: each line keeps its significant whitespace and
203+
* is joined to the next by a hard break, so single newlines render as `<br>`.
203204
*
204205
* @param \Djot\Parser\BlockParser $blockParser
205206
* @param array<int, string> $stanza
@@ -212,19 +213,7 @@ protected function buildStanza(BlockParser $blockParser, array $stanza, int $bas
212213
$last = count($stanza) - 1;
213214
$index = 0;
214215
foreach ($stanza as $line) {
215-
// Emit leading whitespace via the internal non-breaking-space
216-
// placeholder (U+E000, the same sentinel the parser uses for an
217-
// escaped space). The HTML renderer turns it into a &nbsp; entity so
218-
// the indent survives whitespace collapsing; Markdown keeps a real
219-
// non-breaking space (U+00A0); plain text and ANSI use a normal space.
220-
// A private-use character is used so it never collides with a literal
221-
// U+00A0 in the author's own text. Tabs expand to four-column stops.
222-
[$columns, $content] = $this->splitLeadingWhitespace($line);
223-
if ($columns > 0) {
224-
$paragraph->appendChild(new Text(str_repeat("\u{E000}", $columns)));
225-
}
226-
227-
$inlineParser->parse($paragraph, $content, $baseLine + $index);
216+
$this->appendLine($paragraph, $inlineParser, $line, $baseLine + $index);
228217
if ($index < $last) {
229218
$paragraph->appendChild(new HardBreak());
230219
}
@@ -235,27 +224,80 @@ protected function buildStanza(BlockParser $blockParser, array $stanza, int $bas
235224
}
236225

237226
/**
238-
* Split a line into its leading-whitespace width (in columns, tabs expanded
239-
* to four-column stops) and the remaining content.
227+
* Append one verse line, preserving its significant whitespace.
228+
*
229+
* In a line block every space the author typed is meaningful (Pandoc's
230+
* definition), so a verse keeps not only its leading indent but also the
231+
* medial gaps used for alignment - the caesura of Old English verse, columns
232+
* in an address, chords aligned above lyrics. The rule:
240233
*
241-
* @return array{0: int, 1: string}
234+
* - **Leading** whitespace (indentation): always preserved, even one space.
235+
* - **Inner / trailing** runs of **two or more** columns (medial gaps,
236+
* inline alignment): preserved.
237+
* - A **single** inner space stays an ordinary, collapsible space, so a long
238+
* line can still wrap between words.
239+
*
240+
* Each preserved column is emitted via the internal non-breaking-space
241+
* placeholder (U+E000, the same sentinel the parser uses for an escaped
242+
* space): the HTML renderer turns it into `&nbsp;`, Markdown keeps a real
243+
* U+00A0 (so the gap survives a round-trip and is never read as indented
244+
* code), and plain text / ANSI use an ordinary space. A private-use character
245+
* is used so it never collides with a literal U+00A0 in the author's text.
246+
* Tabs expand to four-column stops. Text segments between gaps are
247+
* inline-parsed, so emphasis, links, and the rest still work.
248+
*
249+
* @param \Djot\Node\Block\Paragraph $paragraph
250+
* @param \Djot\Parser\InlineParser $inlineParser
251+
* @param string $line
252+
* @param int $lineNo
242253
*/
243-
protected function splitLeadingWhitespace(string $line): array
254+
protected function appendLine(Paragraph $paragraph, InlineParser $inlineParser, string $line, int $lineNo): void
244255
{
245-
$column = 0;
246-
$offset = 0;
247256
$length = strlen($line);
257+
$offset = 0;
258+
$column = 0;
259+
$text = '';
260+
$seenContent = false;
261+
248262
while ($offset < $length) {
249-
if ($line[$offset] === ' ') {
263+
$char = $line[$offset];
264+
if ($char !== ' ' && $char !== "\t") {
265+
$text .= $char;
266+
$seenContent = true;
250267
$column++;
251-
} elseif ($line[$offset] === "\t") {
252-
$column += 4 - ($column % 4);
253-
} else {
254-
break;
268+
$offset++;
269+
270+
continue;
271+
}
272+
273+
$width = 0;
274+
while ($offset < $length && ($line[$offset] === ' ' || $line[$offset] === "\t")) {
275+
if ($line[$offset] === "\t") {
276+
$width += 4 - (($column + $width) % 4);
277+
} else {
278+
$width++;
279+
}
280+
$offset++;
255281
}
256-
$offset++;
282+
$column += $width;
283+
284+
// Leading indent is always significant; an inner or trailing gap only
285+
// from two columns up. A lone inner space stays a normal, wrappable space.
286+
if (!$seenContent || $width >= 2) {
287+
if ($text !== '') {
288+
$inlineParser->parse($paragraph, $text, $lineNo);
289+
$text = '';
290+
}
291+
$paragraph->appendChild(new Text(str_repeat("\u{E000}", $width)));
292+
293+
continue;
294+
}
295+
296+
$text .= ' ';
257297
}
258298

259-
return [$column, substr($line, $offset)];
299+
if ($text !== '') {
300+
$inlineParser->parse($paragraph, $text, $lineNo);
301+
}
260302
}
261303
}

tests/TestCase/Extension/LineBlockDivExtensionTest.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,71 @@ public function testWorksInsideBlockquotedList(): void
221221
$this->assertStringContainsString("alpha<br>\n&nbsp;&nbsp;beta", $html);
222222
}
223223

224+
public function testMedialGapIsPreservedAsNonBreakingSpaces(): void
225+
{
226+
// A line block keeps medial alignment, not only the leading indent: the
227+
// caesura of Old English verse is a run of spaces in the middle of a line.
228+
$djot = "::: |\nHwaet! We Gardena in geardagum\n:::";
229+
230+
$html = $this->converter()->convert($djot);
231+
232+
$this->assertStringContainsString('Hwaet! We Gardena' . str_repeat('&nbsp;', 4) . 'in geardagum', $html);
233+
}
234+
235+
public function testSingleInnerSpaceStaysOrdinary(): void
236+
{
237+
// Ordinary word spacing must stay a real, collapsible space so the line
238+
// can still wrap; only runs of two or more columns are treated as a gap.
239+
$djot = "::: |\nThe limerick packs laughs\n:::";
240+
241+
$html = $this->converter()->convert($djot);
242+
243+
$this->assertStringContainsString('The limerick packs laughs', $html);
244+
$this->assertStringNotContainsString('&nbsp;', $html);
245+
}
246+
247+
public function testMedialGapNonHtmlUsesRegularSpaces(): void
248+
{
249+
// Plain text keeps the gap visible as ordinary spaces, with no placeholder
250+
// leaking through.
251+
$document = $this->converter()->parse("::: |\nleft right\n:::");
252+
$text = (new PlainTextRenderer())->render($document);
253+
254+
$this->assertStringContainsString('left right', $text);
255+
$this->assertStringNotContainsString(self::NBSP_PLACEHOLDER, $text);
256+
}
257+
258+
public function testMedialGapMarkdownUsesNonBreakingSpaces(): void
259+
{
260+
// Markdown round-trips the gap as real U+00A0 so re-rendering keeps it.
261+
$document = $this->converter()->parse("::: |\nleft right\n:::");
262+
$markdown = (new MarkdownRenderer())->render($document);
263+
264+
$this->assertStringContainsString('left' . self::NBSP . self::NBSP . 'right', $markdown);
265+
$this->assertStringNotContainsString(self::NBSP_PLACEHOLDER, $markdown);
266+
}
267+
268+
public function testMedialTabExpandsToColumnStop(): void
269+
{
270+
// A tab used as a medial gap expands to the next four-column stop.
271+
$djot = "::: |\nab\tcd\n:::";
272+
273+
$html = $this->converter()->convert($djot);
274+
275+
// "ab" sits at columns 0-1; the tab fills columns 2-3 -> two nbsp.
276+
$this->assertStringContainsString('ab' . str_repeat('&nbsp;', 2) . 'cd', $html);
277+
}
278+
279+
public function testMedialGapKeepsInlineMarkup(): void
280+
{
281+
// Inline markup on either side of a gap still parses.
282+
$djot = "::: |\n_em_ [link](https://example.com)\n:::";
283+
284+
$html = $this->converter()->convert($djot);
285+
286+
$this->assertStringContainsString('<em>em</em>' . str_repeat('&nbsp;', 4) . '<a href="https://example.com">link</a>', $html);
287+
}
288+
224289
public function testPlainDivWithRealClassIsUntouched(): void
225290
{
226291
$djot = "::: warning\nHello\n:::";

0 commit comments

Comments
 (0)