feat: render fixed-width text for nex

This commit is contained in:
randogoth 2026-10-04 20:27:29 +03:00
parent 2f38b8b929
commit 9e1cfed284
19 changed files with 1160 additions and 44 deletions

14
text/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "itsybitsy-text"
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
description = "Fixed-width plain text output for itsybitsy"
license = "Apache-2.0"
publish = false
[dependencies]
itsybitsy-core = { path = "../core" }
[lints.rust]
unsafe_code = "forbid"

105
text/src/inline.rs Normal file
View file

@ -0,0 +1,105 @@
//! Inline markup flattened to plain text.
//!
//! Plain text has no markup to carry emphasis, so it is dropped and the words
//! kept. A link becomes `label (url)`: self-contained, and readable without
//! scrolling to a reference list somewhere else. md2txt's `text` renderer emits
//! numbered markers instead but never writes the list they point at, so the
//! numbers lead nowhere.
use itsybitsy_core::ir::{Doc, Inline};
/// Flatten a run of inlines, spelling out where each link points.
pub fn flatten(inline: &[Inline]) -> String {
let mut out = String::new();
write(inline, &mut out);
out
}
fn write(inline: &[Inline], out: &mut String) {
for item in inline {
match item {
Inline::Text(text) => out.push_str(text),
// Backticks are the only markup worth keeping: they mark where a
// literal begins and end, which prose otherwise cannot show.
Inline::Code(code) => out.push_str(&format!("`{code}`")),
Inline::Emph(inner) | Inline::Strong(inner) | Inline::Strike(inner) => {
write(inner, out)
}
Inline::Link { href, label, .. } => {
let text = Doc::plain_text(label);
write_target(&text, href, out);
}
Inline::Image { src, alt, .. } => {
let text = Doc::plain_text(alt);
write_target(&text, src, out);
}
Inline::SoftBreak => out.push(' '),
// A hard break is honoured by the layout, which splits on it.
Inline::HardBreak => out.push('\n'),
// Raw markup shown literally would be worse than leaving it out.
Inline::Html(_) => {}
}
}
}
/// `label (url)`, or just the url when the label would repeat it.
fn write_target(label: &str, url: &str, out: &mut String) {
let label = label.trim();
if label.is_empty() || label == url {
out.push_str(url);
} else {
out.push_str(&format!("{label} ({url})"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(value: &str) -> Vec<Inline> {
vec![Inline::Text(value.into())]
}
#[test]
fn emphasis_is_dropped_and_its_words_kept() {
let inline = vec![
Inline::Text("a ".into()),
Inline::Strong(text("b")),
Inline::Text(" ".into()),
Inline::Emph(text("c")),
];
assert_eq!(flatten(&inline), "a b c");
}
#[test]
fn code_keeps_its_backticks_to_mark_where_a_literal_ends() {
assert_eq!(flatten(&[Inline::Code("x = 1".into())]), "`x = 1`");
}
#[test]
fn a_link_spells_out_where_it_points() {
let inline =
vec![Inline::Link { href: "/about".into(), title: None, label: text("about page") }];
assert_eq!(flatten(&inline), "about page (/about)");
}
#[test]
fn a_link_whose_label_is_its_url_is_not_repeated() {
let url = "https://example.com/";
let inline = vec![Inline::Link { href: url.into(), title: None, label: text(url) }];
assert_eq!(flatten(&inline), url);
}
#[test]
fn an_image_is_treated_like_a_link() {
let inline =
vec![Inline::Image { src: "/i.png".into(), title: None, alt: text("a photo") }];
assert_eq!(flatten(&inline), "a photo (/i.png)");
}
#[test]
fn raw_html_is_left_out() {
let inline = vec![Inline::Text("a".into()), Inline::Html("<br>".into())];
assert_eq!(flatten(&inline), "a");
}
}

319
text/src/layout.rs Normal file
View file

@ -0,0 +1,319 @@
//! Blocks laid out as fixed-width lines.
use itsybitsy_core::config::{HeadingStyle, PageSettings};
use itsybitsy_core::ir::{Align, Block, Inline};
use itsybitsy_core::wrap::{display_width, pad, wrap};
use crate::inline::flatten;
/// Where a block's lines sit within the content width.
#[derive(Clone, Copy, Default)]
struct Placement {
align: Option<Align>,
/// Columns held back on both sides, from a directive's `margin=`.
inset: usize,
}
pub struct Layout<'a> {
settings: &'a PageSettings,
/// Columns available for content, after both margins.
content_width: usize,
lines: Vec<String>,
/// Set by a container that has just emitted its own separating blank, so its
/// first child does not emit a second one.
suppress_gap: bool,
}
impl<'a> Layout<'a> {
/// `width` is the full terminal width; the margins come out of it.
pub fn new(settings: &'a PageSettings, width: Option<u16>) -> Self {
let total = width.unwrap_or(80) as usize;
let margins = settings.margin_left as usize + settings.margin_right as usize;
// Never let margins consume the whole line; content needs somewhere to go.
let content_width = total.saturating_sub(margins).max(8);
Layout { settings, content_width, lines: Vec::new(), suppress_gap: false }
}
pub fn finish(mut self) -> Vec<u8> {
while self.lines.last().is_some_and(|line| line.trim().is_empty()) {
self.lines.pop();
}
let mut out = self.lines.join("\n");
out.push('\n');
out.into_bytes()
}
pub fn blocks(&mut self, blocks: &[Block]) {
for block in blocks {
self.block(block, "", Placement::default());
}
}
/// Lay out one block. `prefix` is prepended inside the margin, which is how
/// quotes and list continuations carry their decoration.
fn block(&mut self, block: &Block, prefix: &str, place: Placement) {
match block {
Block::Heading { level, inline } => {
self.gap(prefix, place);
let text = flatten(inline);
let index = (*level).clamp(1, 6) as usize - 1;
match &self.settings.heading_styles[index] {
HeadingStyle::Underline(rule) => {
let wrapped = self.wrapped(&text, prefix, place);
let width = wrapped.iter().map(|l| display_width(l)).max().unwrap_or(0);
self.extend(wrapped, prefix, place);
self.push(&rule.to_string().repeat(width), prefix, place);
}
HeadingStyle::Markers => {
let marked = format!("{} {text}", "#".repeat(*level as usize));
let wrapped = self.wrapped(&marked, prefix, place);
self.extend(wrapped, prefix, place);
}
HeadingStyle::Plain => {
let wrapped = self.wrapped(&text, prefix, place);
self.extend(wrapped, prefix, place);
}
}
}
Block::Paragraph(inline) => {
self.gap(prefix, place);
// A hard break ends a line without ending the paragraph.
for segment in flatten(inline).split('\n') {
let wrapped = self.wrapped(segment, prefix, place);
self.extend(wrapped, prefix, place);
}
}
Block::CodeBlock { lines, .. } => {
self.gap(prefix, place);
self.verbatim(lines, prefix, place, self.settings.code_block_line_numbers);
}
Block::Art { lines, .. } => {
self.gap(prefix, place);
// Art is never numbered or wrapped: its shape is the content.
self.verbatim(lines, prefix, place, false);
}
Block::BlockQuote(inner) => {
self.gap(prefix, place);
// Without bars a quote is still indented, or it would be
// indistinguishable from body text.
let bar = if self.settings.blockquote_bars { "| " } else { " " };
self.suppress_gap = true;
let nested = format!("{prefix}{bar}");
for block in inner {
self.block(block, &nested, place);
self.suppress_gap = false;
}
}
Block::List { ordered, start, items } => {
self.gap(prefix, place);
for (index, item) in items.iter().enumerate() {
let marker = if *ordered {
format!("{}. ", start + index as u64)
} else {
"- ".to_string()
};
self.item(item, prefix, &marker, place);
}
}
Block::Table { alignments, head, rows } => {
self.gap(prefix, place);
self.table(alignments, head, rows, prefix, place);
}
Block::Rule => {
self.gap(prefix, place);
let width = self.room(prefix, place);
self.push(&"-".repeat(width), prefix, place);
}
Block::Aligned { align, margin, block } => self.block(
block,
prefix,
Placement { align: Some(*align), inset: margin.unwrap_or(0) as usize },
),
// Nothing paginates here, so a divider has nothing to divide.
Block::CardBreak { .. } => {}
Block::Html(_) => {}
}
}
/// One list item: the marker on the first line, continuations aligned under
/// the text rather than under the marker.
fn item(&mut self, blocks: &[Block], prefix: &str, marker: &str, place: Placement) {
let hanging = format!("{prefix}{}", " ".repeat(display_width(marker)));
let first = format!("{prefix}{marker}");
// No suppression flag here: an item's first paragraph is emitted directly
// rather than through `block`, so it never gaps, and anything after it
// relies on `unblank` instead. Setting the flag would leak it past the
// list and swallow the next block's separating line.
let mut started = false;
for block in blocks {
match block {
Block::Paragraph(inline) if !started => {
started = true;
let text = flatten(inline);
let wrapped = self.wrapped(&text, &first, place);
for (index, line) in wrapped.into_iter().enumerate() {
let carrier = if index == 0 { &first } else { &hanging };
self.push(&line, carrier, place);
}
}
// A nested list, or anything after the item's first paragraph,
// sits at the hanging indent.
Block::List { .. } => {
let indent = format!(
"{hanging}{}",
" ".repeat(self.settings.list_indent.saturating_sub(2) as usize)
);
// No blank line before a nested list: it belongs to the item.
let before = self.lines.len();
self.block(block, &indent, place);
self.unblank(before);
}
other => {
let before = self.lines.len();
self.block(other, &hanging, place);
self.unblank(before);
}
}
}
if !started {
self.push("", &first, place);
}
}
fn table(
&mut self,
alignments: &[Option<Align>],
head: &[Vec<Inline>],
rows: &[Vec<Vec<Inline>>],
prefix: &str,
place: Placement,
) {
let mut grid: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
if !head.is_empty() {
grid.push(head.iter().map(|cell| flatten(cell)).collect());
}
for row in rows {
grid.push(row.iter().map(|cell| flatten(cell)).collect());
}
let columns = grid.iter().map(Vec::len).max().unwrap_or(0);
if columns == 0 {
return;
}
let widths: Vec<usize> = (0..columns)
.map(|index| {
grid.iter()
.map(|row| row.get(index).map_or(0, |c| display_width(c)))
.max()
.unwrap_or(0)
})
.collect();
for (index, row) in grid.iter().enumerate() {
let cells: Vec<String> = (0..columns)
.map(|column| {
let cell = row.get(column).map(String::as_str).unwrap_or("");
cell_aligned(cell, widths[column], alignments.get(column).copied().flatten())
})
.collect();
self.push(cells.join(" ").trim_end(), prefix, place);
if index == 0 && !head.is_empty() {
let rule: Vec<String> = widths.iter().map(|width| "-".repeat(*width)).collect();
self.push(&rule.join(" "), prefix, place);
}
}
}
/// Lines kept exactly as written, optionally numbered.
fn verbatim(&mut self, lines: &[String], prefix: &str, place: Placement, numbered: bool) {
let digits = lines.len().to_string().len().max(2);
for (index, line) in lines.iter().enumerate() {
let body =
if numbered { format!("{:0digits$} | {line}", index + 1) } else { line.clone() };
if self.settings.wrap_code_blocks {
for piece in wrap(&body, self.room(prefix, place)) {
self.push(&piece, prefix, place);
}
} else {
// Over-long lines are left to overflow: wrapping code changes
// what it says.
self.push(&body, prefix, place);
}
}
}
/// Columns available to content after the prefix and any inset.
fn room(&self, prefix: &str, place: Placement) -> usize {
self.content_width
.saturating_sub(display_width(prefix))
.saturating_sub(place.inset * 2)
.max(1)
}
fn wrapped(&self, text: &str, prefix: &str, place: Placement) -> Vec<String> {
wrap(text, self.room(prefix, place))
}
fn extend(&mut self, lines: Vec<String>, prefix: &str, place: Placement) {
for line in lines {
self.push(&line, prefix, place);
}
}
/// Emit one line: left margin, then the prefix, then the content.
fn push(&mut self, line: &str, prefix: &str, place: Placement) {
let rendered = self.rendered(line, prefix, place);
self.lines.push(rendered);
}
/// One line's text, without appending it.
fn rendered(&self, line: &str, prefix: &str, place: Placement) -> String {
let room = self.room(prefix, place);
let offset = match place.align {
Some(Align::Center) => room.saturating_sub(display_width(line)) / 2,
Some(Align::Right) => room.saturating_sub(display_width(line)),
_ => 0,
};
let content = format!("{}{line}", " ".repeat(place.inset + offset));
let mut out = " ".repeat(self.settings.margin_left as usize);
out.push_str(prefix);
out.push_str(&content);
// A margin is not content, so a blank line stays blank.
out.trim_end().to_string()
}
/// The blank lines that separate blocks, suppressed at the very start.
///
/// Carries the prefix, so a blank line inside a quote keeps its bar and the
/// quote reads as one block rather than two.
fn gap(&mut self, prefix: &str, place: Placement) {
if std::mem::take(&mut self.suppress_gap) || self.lines.is_empty() {
return;
}
let blank = self.rendered("", prefix, place);
let present = self.lines.iter().rev().take_while(|line| **line == blank).count();
for _ in present..self.settings.paragraph_spacing as usize {
self.lines.push(blank.clone());
}
}
/// Drop a leading blank line a nested block inserted at `from`: it belongs
/// to the item, not after it.
fn unblank(&mut self, from: usize) {
if self.lines.get(from).is_some_and(|line| line.trim().is_empty()) {
self.lines.remove(from);
}
}
}
fn cell_aligned(cell: &str, width: usize, align: Option<Align>) -> String {
let slack = width.saturating_sub(display_width(cell));
match align {
Some(Align::Right) => format!("{}{cell}", " ".repeat(slack)),
Some(Align::Center) => {
let left = slack / 2;
format!("{}{}", " ".repeat(left), pad(cell, width - left))
}
_ => pad(cell, width),
}
}

288
text/src/lib.rs Normal file
View file

@ -0,0 +1,288 @@
//! Fixed-width plain text, for Nex and later Gopher.
//!
//! One renderer, not two. md2txt ships a `text` and a `nex` renderer that differ
//! in exactly two things — whether headings get FIGlet banners, and whether links
//! are inlined or numbered — and both of those are now configuration. Its
//! numbered form never writes the reference list its numbers point at, so the
//! inline form is the only one that works and is the default here.
//!
//! Unlike gemtext this wraps, because Nex and Gopher clients do not.
mod inline;
mod layout;
use itsybitsy_core::Error;
use itsybitsy_core::ir::Doc;
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
use crate::layout::Layout;
/// The classic 80-column convention, which Nex and Gopher readers expect.
const DEFAULT_WIDTH: u16 = 80;
pub struct Text;
impl Renderer for Text {
fn id(&self) -> &'static str {
"text"
}
fn media_type(&self) -> &'static str {
"text/plain; charset=utf-8"
}
fn default_width(&self) -> Option<u16> {
Some(DEFAULT_WIDTH)
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
let mut layout = Layout::new(ctx.settings, ctx.width);
layout.blocks(&doc.blocks);
Ok(Rendered::body(layout.finish()))
}
}
#[cfg(test)]
mod tests {
use itsybitsy_core::config::{HeadingStyle, PageSettings};
use itsybitsy_core::ir::{Align, Block, Inline};
use itsybitsy_core::parse;
use super::*;
/// Render at a narrow width so wrapping is visible, with no margins so the
/// assertions read as the content itself.
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
let doc = parse::markdown(markdown);
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
}
fn bare() -> PageSettings {
PageSettings { margin_left: 0, margin_right: 0, ..Default::default() }
}
fn render(markdown: &str) -> String {
render_with(&bare(), 40, markdown)
}
#[test]
fn headings_are_underlined_with_a_character_per_level() {
assert_eq!(render("# One\n"), "One\n===\n");
assert_eq!(render("## Two\n"), "Two\n---\n");
assert_eq!(render("### Three\n"), "Three\n~~~~~\n");
}
#[test]
fn deeper_headings_keep_their_markers_since_underlines_run_out() {
assert_eq!(render("#### Four\n"), "#### Four\n");
assert_eq!(render("###### Six\n"), "###### Six\n");
}
#[test]
fn a_heading_style_can_be_configured_per_level() {
let mut settings = bare();
settings.heading_styles[0] = HeadingStyle::Underline('*');
assert_eq!(render_with(&settings, 40, "# One\n"), "One\n***\n");
settings.heading_styles[0] = HeadingStyle::Plain;
assert_eq!(render_with(&settings, 40, "# One\n"), "One\n");
settings.heading_styles[0] = HeadingStyle::Markers;
assert_eq!(render_with(&settings, 40, "# One\n"), "# One\n");
}
#[test]
fn paragraphs_are_wrapped_because_nex_clients_do_not() {
assert_eq!(
render("one two three four five six seven eight nine\n"),
"one two three four five six seven eight\nnine\n"
);
}
#[test]
fn margins_come_out_of_the_width() {
let settings = PageSettings { margin_left: 2, margin_right: 2, ..Default::default() };
// 40 columns less four of margin leaves 36 for content.
let out = render_with(&settings, 40, "aaaa bbbb cccc dddd eeee ffff gggg hhhh\n");
for line in out.lines().filter(|l| !l.is_empty()) {
assert!(line.starts_with(" "), "{line:?}");
assert!(line.len() <= 38, "{line:?} is {} wide", line.len());
}
}
#[test]
fn one_blank_line_separates_blocks_by_default() {
// md2txt emits two, which reads as double-spaced throughout.
assert_eq!(render("a\n\nb\n"), "a\n\nb\n");
}
#[test]
fn the_separation_is_configurable() {
let settings = PageSettings { paragraph_spacing: 2, ..bare() };
assert_eq!(render_with(&settings, 40, "a\n\nb\n"), "a\n\n\nb\n");
}
#[test]
fn lists_hang_their_continuations_under_the_text() {
assert_eq!(
render("- one two three four five six seven eight\n- short\n"),
"- one two three four five six seven\n eight\n- short\n"
);
}
#[test]
fn an_ordered_list_numbers_from_its_start() {
assert_eq!(render("3. a\n4. b\n"), "3. a\n4. b\n");
}
#[test]
fn a_nested_list_is_indented_under_its_item() {
assert_eq!(render("- a\n - b\n- c\n"), "- a\n - b\n- c\n");
}
#[test]
fn quotes_carry_a_bar() {
assert_eq!(render("> a\n>\n> b\n"), "| a\n|\n| b\n");
// Without bars the quote is still indented, or it would read as body text.
let settings = PageSettings { blockquote_bars: false, ..bare() };
assert_eq!(render_with(&settings, 40, "> a\n"), " a\n");
}
#[test]
fn a_nested_quote_gains_another_bar() {
assert_eq!(render("> a\n>\n> > b\n"), "| a\n|\n| | b\n");
}
#[test]
fn code_blocks_are_numbered_and_left_unwrapped() {
// Wrapping code would change what it says.
assert_eq!(
render("```\nlet x = 1;\nlet y = 2;\n```\n"),
"01 | let x = 1;\n02 | let y = 2;\n"
);
}
#[test]
fn code_numbering_can_be_turned_off() {
let settings = PageSettings { code_block_line_numbers: false, ..bare() };
assert_eq!(render_with(&settings, 40, "```\nx\n```\n"), "x\n");
}
#[test]
fn an_over_long_code_line_overflows_unless_wrapping_is_asked_for() {
let long = "a".repeat(60);
let settings = PageSettings { code_block_line_numbers: false, ..bare() };
assert_eq!(render_with(&settings, 40, &format!("```\n{long}\n```\n")), format!("{long}\n"));
let wrapping = PageSettings { wrap_code_blocks: true, ..settings };
let out = render_with(&wrapping, 40, &format!("```\n{long}\n```\n"));
assert_eq!(out.lines().count(), 2);
}
#[test]
fn tables_are_rendered_as_aligned_columns() {
// md2txt drops tables entirely; this is the flaw that fixes.
assert_eq!(
render("| Format | Port |\n| --- | --- |\n| Nex | 1900 |\n"),
"Format Port\n------ ----\nNex 1900\n"
);
}
#[test]
fn a_tables_column_alignment_is_honoured() {
assert_eq!(
render("| name | n |\n| :--- | --: |\n| ab | 1 |\n| c | 22 |\n"),
"name n\n---- --\nab 1\nc 22\n"
);
}
#[test]
fn a_rule_spans_the_content_width() {
let out = render("a\n\n---\n\nb\n");
assert!(out.contains(&"-".repeat(40)), "{out:?}");
}
#[test]
fn links_say_where_they_point() {
assert_eq!(render("See [about](/about).\n"), "See about (/about).\n");
}
/// Render blocks directly: alignment and card dividers are produced by
/// `parse::document`, which needs a file, so the unit tests build them.
fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec<Block>) -> String {
let doc = Doc { blocks, first_h1: None };
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
}
fn para(text: &str) -> Block {
Block::Paragraph(vec![Inline::Text(text.into())])
}
#[test]
fn alignment_is_honoured_here_because_the_width_is_fixed() {
// The one format family that can express it; gemtext and HTML cannot.
let centred =
Block::Aligned { align: Align::Center, margin: None, block: Box::new(para("abc")) };
assert_eq!(render_blocks(&bare(), 11, vec![centred]), " abc\n");
let right =
Block::Aligned { align: Align::Right, margin: None, block: Box::new(para("abc")) };
assert_eq!(render_blocks(&bare(), 11, vec![right]), " abc\n");
}
#[test]
fn an_alignment_margin_holds_columns_back_on_both_sides() {
let right =
Block::Aligned { align: Align::Right, margin: Some(2), block: Box::new(para("abc")) };
// 11 columns less two of inset each side leaves 7; "abc" ends at column 9.
assert_eq!(render_blocks(&bare(), 11, vec![right]), " abc\n");
}
#[test]
fn a_card_divider_emits_nothing_because_text_does_not_paginate() {
let blocks = vec![para("a"), Block::CardBreak { title: Some("W".into()) }, para("b")];
assert_eq!(render_blocks(&bare(), 40, blocks), "a\n\nb\n");
}
#[test]
fn output_ends_in_exactly_one_newline_with_no_trailing_blanks() {
for source in ["a\n", "# a\n", "- a\n", "a\n\n---\n"] {
let out = render(source);
assert!(out.ends_with('\n'), "{source:?} -> {out:?}");
assert!(!out.ends_with("\n\n"), "{source:?} -> {out:?}");
}
}
}
#[cfg(test)]
mod separation_tests {
use itsybitsy_core::config::PageSettings;
use itsybitsy_core::parse;
use super::*;
fn render(markdown: &str) -> String {
let doc = parse::markdown(markdown);
let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() };
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40) };
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
}
#[test]
fn a_block_after_a_list_is_still_separated_from_it() {
// Regression: the flag that stops a list item gapping twice must not
// outlive the list and swallow the next block's blank line.
assert_eq!(render("- a\n- b\n\nafter\n"), "- a\n- b\n\nafter\n");
assert_eq!(render("- a\n\n| h |\n| --- |\n| c |\n"), "- a\n\nh\n-\nc\n");
}
#[test]
fn a_block_after_a_quote_is_still_separated_from_it() {
assert_eq!(render("> q\n\nafter\n"), "| q\n\nafter\n");
}
#[test]
fn consecutive_lists_are_separated() {
assert_eq!(render("- a\n\n1. b\n"), "- a\n\n1. b\n");
}
}