一个 Java 文件实现《孫子兵法·始計篇》中英对照阅读器

10 阅读13分钟

背景

用一个 java 文件做出能翻页的《論語·學而》中英对照本 那篇文章用 GUI 来展示《論語·學而》的内容:中间繁体原文,右边英译,点按钮时两栏一起换。ctext.org 上的《孫子兵法·始計篇》按七个小节排布,还带有 Giles 1910 年的英译,很适合用同样的骨架。我用 trae 给《孫子兵法·始計篇》也生成了类似的 GUI。

界面与功能

image.png

02_section6.png

03_section7.png

  • 左栏"段落"下方是 7 个段号按钮,点击直达;当前段朱砂底高亮,同时禁用
  • ‹ 上一段 在第 1 段自动变灰
  • 下一段 › 在第 7 段自动变灰
  • 中间繁体原文、右侧 Lionel Giles(1910)英译,均自动换行;切换段落时两侧滚动位置复位
  • 键盘:← / → 切换段落,Home / End 跳到首尾
  • 页脚显示"第 n 段 / 共 7 段"以及快捷键提示
  • 页脚右侧"資料來源 · ctext.org ↗"是可点击链接,悬停变朱红;点击调系统浏览器打开 ctext.org 的《始計》页,若当前环境不支持浏览(如 headless)则弹窗展示 URL
  • --preview 模式不弹窗,直接把界面画成 PNG——本文配图就是这么生成的

界面文案统一用繁体中文;标题、栏目、按钮的字族按 Kaiti SC → Noto Serif CJK TC → Songti SC 的顺序回退,保证不同系统上都能落到一款中文衬线字体。

如何运行

请将 完整代码 保存为 SunziReaderGui.java,使用下列命令可以编译、运行。

javac -d /tmp/sunzi-build SunziReaderGui.java
java -cp /tmp/sunzi-build SunziReaderGui
java -cp /tmp/sunzi-build SunziReaderGui --section 6
java -Djava.awt.headless=true -cp /tmp/sunzi-build SunziReaderGui --preview out.png 1

需要 JDK 15+(用到了文本块与 switch 表达式),我在本地 JDK 21 环境上验证过。

实现说明

  • 布局:BorderLayout 三栏,左栏固定 212px、右栏固定 430px,中间原文栏吃掉剩余宽度
  • 数据:原文与英译各是一个 7 元素的 String[],下标即"段号减一"
  • 联动:所有状态变化都收在 showSection() 一个方法里——正文、栏目标题、按钮态、翻页边界、页脚进度一次刷新完,不会出现切了一半的中间态
  • 自绘:卡片、段号按钮、翻页胶囊、印章都用 paintComponent 画的,外观不随系统主题变;当前段「高亮且禁用」靠选中态优先于禁用态来保留
  • 字体:chooseFont() 传入候选字族列表,按可用字体名逐个匹配,全都不存在时回落到 Font.SERIF
  • 来源链接:JLabel 加下划线字型与手型光标,点击走 Desktop.browse();不支持时用 JOptionPane 展示地址,避免静默失效
  • 离屏出图:buildPanel() 后手工 doLayout 整棵组件树,再 printAll() 到 BufferedImage,所以不依赖真实窗口

说明:这个工具的代码是我借助 trae 生成的,我在本机 JDK 21 上核对过。

参考资料

完整代码

import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Container;
import java.awt.Cursor;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GraphicsEnvironment;
import java.awt.GridLayout;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.font.TextAttribute;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import javax.imageio.ImageIO;
import javax.swing.AbstractAction;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextPane;
import javax.swing.JToggleButton;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;

/**
 * A simple reader for the first chapter of The Art of War (《孫子·始計》), built with
 * plain Swing - modeled on the Analects reader from
 * <a href="https://juejin.cn/post/7702637146160758826">this article</a>.
 * <p>
 * Layout: the left column holds the seven section buttons plus prev/next pills,
 * the center column shows the traditional Chinese original, and the right column
 * shows Lionel Giles's English translation (both texts from
 * <a href="https://ctext.org/art-of-war/laying-plans/zh">ctext.org</a>).
 * The current section button is selected and disabled, prev/next are disabled at
 * the ends, and arrow keys move between sections as well.
 * <p>
 * Run with:
 *   javac SunziReaderGui.java
 *   java  SunziReaderGui
 * <p>
 * Options:
 *   java SunziReaderGui --section 5           open at section 5
 *   java SunziReaderGui --preview out.png     render the window offscreen
 *   java SunziReaderGui --preview out.png 5   render section 5 offscreen
 */
public class SunziReaderGui {

    private static final int SECTION_COUNT = 7;

    private static final String SOURCE_URL = "https://ctext.org/art-of-war/laying-plans/zh";

    // ---- text: traditional Chinese original + Giles translation (ctext.org) --

    private static final String[] CHINESE = {
            "孫子曰:兵者,國之大事,死生之地,存亡之道,不可不察也。",
            "故經之以五事,校之以計,而索其情,一曰道,二曰天,三曰地,四曰將,五曰法。",
            "道者,令民與上同意也,可與之死,可與之生,而不畏危。天者,陰陽,寒暑,時制也。"
                    + "地者,遠近,險易,廣狹,死生也。將者,智,信,仁,勇,嚴也。法者,曲制,"
                    + "官道,主用也。凡此五者,將莫不聞,知之者勝,不知者不勝。",
            "故校之以計,而索其情。曰:主孰有道,將孰有能,天地孰得,法令孰行,兵眾孰強,"
                    + "士卒孰練,賞罰孰明,吾以此知勝負矣。將聽吾計,用之必勝,留之;"
                    + "將不聽吾計,用之必敗,去之。",
            "計利以聽,乃為之勢,以佐其外;勢者,因利而制權也。",
            "兵者,詭道也。故能而示之不能,用而示之不用,近而示之遠,遠而示之近。"
                    + "利而誘之,亂而取之,實而備之,強而避之,怒而撓之,卑而驕之,佚而勞之,"
                    + "親而離之。攻其無備,出其不意,此兵家之勝,不可先傳也。",
            "夫未戰而廟算勝者,得算多也;未戰而廟算不勝者,得算少也;多算勝,少算不勝,"
                    + "而況於無算乎?吾以此觀之,勝負見矣。",
    };

    private static final String[] ENGLISH = {
            """
            Sunzi said: The art of war is of vital importance to the State. \
            It is a matter of life and death, a road either to safety or to ruin. \
            Hence it is a subject of inquiry which can on no account be neglected.""",
            """
            The art of war, then, is governed by five constant factors, to be taken \
            into account in one's deliberations, when seeking to determine the \
            conditions obtaining in the field. These are: (1) The Moral Law; \
            (2) Heaven; (3) Earth; (4) The Commander; (5) Method and discipline.""",
            """
            The Moral Law causes the people to be in complete accord with their \
            ruler, so that they will follow him regardless of their lives, \
            undismayed by any danger. Heaven signifies night and day, cold and heat, \
            times and seasons. Earth comprises distances, great and small; danger \
            and security; open ground and narrow passes; the chances of life and \
            death. The Commander stands for the virtues of wisdom, sincerity, \
            benevolence, courage and strictness. By method and discipline are to be \
            understood the marshaling of the army in its proper subdivisions, the \
            graduations of rank among the officers, the maintenance of roads by \
            which supplies may reach the army, and the control of military \
            expenditure. These five heads should be familiar to every general: he \
            who knows them will be victorious; he who knows them not will fail.""",
            """
            Therefore, in your deliberations, when seeking to determine the military \
            conditions, let them be made the basis of a comparison, in this wise: \
            (1) Which of the two sovereigns is imbued with the Moral law? \
            (2) Which of the two generals has most ability? (3) With whom lie the \
            advantages derived from Heaven and Earth? (4) On which side is \
            discipline most rigorously enforced? (5) Which army is stronger? \
            (6) On which side are officers and men more highly trained? (7) In \
            which army is there the greater constancy both in reward and punishment? \
            By means of these seven considerations I can forecast victory or defeat. \
            The general that hearkens to my counsel and acts upon it, will conquer: \
            let such a one be retained in command! The general that hearkens not to \
            my counsel nor acts upon it, will suffer defeat - let such a one be \
            dismissed!""",
            """
            While heading the profit of my counsel, avail yourself also of any \
            helpful circumstances over and beyond the ordinary rules. According as \
            circumstances are favorable, one should modify one's plans.""",
            """
            All warfare is based on deception. Hence, when able to attack, we must \
            seem unable; when using our forces, we must seem inactive; when we are \
            near, we must make the enemy believe we are far away; when far away, we \
            must make him believe we are near. Hold out baits to entice the enemy. \
            Feign disorder, and crush him. If he is secure at all points, be \
            prepared for him. If he is in superior strength, evade him. If your \
            opponent is of choleric temper, seek to irritate him. Pretend to be \
            weak, that he may grow arrogant. If he is taking his ease, give him no \
            rest. If his forces are united, separate them. Attack him where he is \
            unprepared, appear where you are not expected. These military devices, \
            leading to victory, must not be divulged beforehand.""",
            """
            Now the general who wins a battle makes many calculations in his temple \
            ere the battle is fought. The general who loses a battle makes but few \
            calculations beforehand. Thus do many calculations lead to victory, and \
            few calculations to defeat: how much more no calculation at all! It is \
            by attention to this point that I can foresee who is likely to win or \
            lose.""",
    };

    // ---- palette: warm paper, ink text, cinnabar accent -----------------------

    private static final Color PAPER = new Color(0xF5, 0xF0, 0xE6);
    private static final Color CARD = new Color(0xFF, 0xFC, 0xF5);
    private static final Color INK = new Color(0x2B, 0x23, 0x20);
    private static final Color SOFT_INK = new Color(0x7A, 0x6E, 0x64);
    private static final Color BODY_ENGLISH = new Color(0x3A, 0x33, 0x2E);
    private static final Color ACCENT = new Color(0x9E, 0x2B, 0x22);
    private static final Color CREAM = new Color(0xFA, 0xF6, 0xEC);
    private static final Color LINE = new Color(0xD8, 0xCF, 0xC0);
    private static final Color BUTTON_IDLE = new Color(0xEF, 0xE8, 0xDA);
    private static final Color BUTTON_HOVER = new Color(0xE3, 0xD5, 0xC0);
    private static final Color PILL_BORDER = new Color(0xC9, 0xA7, 0x9F);
    private static final Color PILL_HOVER = new Color(0xF7, 0xE9, 0xE4);
    private static final Color DISABLED_INK = new Color(0xB8, 0xAE, 0xA2);

    // ---- fonts: candidate families with automatic fallback --------------------

    private static final List<String> CJK_FAMILIES = List.of(
            "Kaiti SC", "STKaiti", "KaiTi", "LXGW WenKai",
            "Noto Serif CJK TC", "Noto Serif CJK SC", "Songti SC", "SimSun");
    private static final List<String> SERIF_FAMILIES = List.of(
            "Georgia", "Times New Roman", "Palatino");

    private static final Font CHINESE_FONT = chooseFont(CJK_FAMILIES, Font.PLAIN, 21);
    private static final Font ENGLISH_FONT = chooseFont(SERIF_FAMILIES, Font.PLAIN, 15);
    private static final Font TITLE_FONT = chooseFont(CJK_FAMILIES, Font.BOLD, 24);
    private static final Font SUBTITLE_FONT = chooseFont(CJK_FAMILIES, Font.PLAIN, 13);
    private static final Font HEADING_FONT = chooseFont(CJK_FAMILIES, Font.BOLD, 14);
    private static final Font FOOTER_FONT = chooseFont(CJK_FAMILIES, Font.PLAIN, 12);
    private static final Font BUTTON_FONT = chooseFont(SERIF_FAMILIES, Font.BOLD, 15);
    private static final Font PILL_FONT = chooseFont(CJK_FAMILIES, Font.BOLD, 13);
    private static final Font SEAL_FONT = chooseFont(CJK_FAMILIES, Font.BOLD, 17);
    private static final Font LINK_FONT = chooseFont(CJK_FAMILIES, Font.PLAIN, 12)
            .deriveFont(Map.of(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_ON));

    // ---- state ----------------------------------------------------------------

    private int section;
    private final SectionButton[] sectionButtons = new SectionButton[SECTION_COUNT];
    private PillButton previousButton;
    private PillButton nextButton;
    private JTextPane chinesePane;
    private JTextPane englishPane;
    private JScrollPane chineseScroll;
    private JScrollPane englishScroll;
    private JLabel chineseHeading;
    private JLabel englishHeading;
    private JLabel progressLabel;
    private JFrame frame;   // created lazily so headless preview never touches it

    public SunziReaderGui(int startSection) {
        this.section = clamp(startSection);
    }

    public static void main(String[] args) throws Exception {
        int startSection = 1;
        String previewOutput = null;
        for (int i = 0; i < args.length; i++) {
            switch (args[i]) {
                case "--section" -> startSection = Integer.parseInt(args[++i]);
                case "--preview" -> {
                    previewOutput = args[++i];
                    if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
                        startSection = Integer.parseInt(args[++i]);
                    }
                }
                default -> {
                    System.err.println("Unknown argument: " + args[i]);
                    printUsage();
                    return;
                }
            }
        }
        if (previewOutput != null) {
            renderPreview(previewOutput, startSection);
            System.out.println("Wrote " + previewOutput);
            return;
        }
        final int initial = startSection;
        SwingUtilities.invokeLater(() -> new SunziReaderGui(initial).show());
    }

    private static void printUsage() {
        System.err.println("""
                Usage:
                  java SunziReaderGui                      open the reader
                  java SunziReaderGui --section 5          open at section 5
                  java SunziReaderGui --preview out.png    render the window offscreen
                  java SunziReaderGui --preview out.png 5  render section 5 offscreen
                """);
    }

    // ---- window ---------------------------------------------------------------

    private void show() {
        frame = new JFrame("孫子兵法 · 始計篇");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        JPanel panel = buildPanel();
        bindKeys(panel);
        showSection(section);
        frame.setContentPane(panel);
        frame.setMinimumSize(new Dimension(1040, 640));
        frame.setSize(1240, 760);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    JPanel buildPanel() {
        JPanel panel = new JPanel(new BorderLayout());
        panel.setBackground(PAPER);
        panel.setBorder(new EmptyBorder(20, 24, 16, 24));
        panel.add(buildHeader(), BorderLayout.NORTH);

        JPanel body = new JPanel(new BorderLayout(16, 0));
        body.setOpaque(false);
        body.add(buildNavigator(), BorderLayout.WEST);
        body.add(buildChineseCard(), BorderLayout.CENTER);
        body.add(buildEnglishCard(), BorderLayout.EAST);
        panel.add(body, BorderLayout.CENTER);

        panel.add(buildFooter(), BorderLayout.SOUTH);
        return panel;
    }

    private JComponent buildHeader() {
        JPanel header = new JPanel(new BorderLayout());
        header.setOpaque(false);
        header.setBorder(new EmptyBorder(0, 4, 16, 4));

        JPanel titles = new JPanel();
        titles.setOpaque(false);
        titles.setLayout(new BoxLayout(titles, BoxLayout.Y_AXIS));
        JLabel title = new JLabel("孫子兵法 · 始計篇");
        title.setFont(TITLE_FONT);
        title.setForeground(INK);
        JLabel subtitle = new JLabel("第一篇 — 英譯:Lionel Giles(1910)");
        subtitle.setFont(SUBTITLE_FONT);
        subtitle.setForeground(SOFT_INK);
        subtitle.setBorder(new EmptyBorder(6, 0, 0, 0));
        titles.add(title);
        titles.add(subtitle);
        header.add(titles, BorderLayout.WEST);

        header.add(new SealLabel("始", "計"), BorderLayout.EAST);
        return header;
    }

    private JComponent buildNavigator() {
        JPanel navigator = new JPanel(new BorderLayout(0, 12));
        navigator.setOpaque(false);
        navigator.setPreferredSize(new Dimension(212, 0));

        JLabel heading = new JLabel("段落");
        heading.setFont(HEADING_FONT);
        heading.setForeground(SOFT_INK);
        navigator.add(heading, BorderLayout.NORTH);

        JPanel grid = new JPanel(new GridLayout(SECTION_COUNT, 1, 8, 8));
        grid.setOpaque(false);
        ButtonGroup group = new ButtonGroup();
        for (int i = 0; i < SECTION_COUNT; i++) {
            final int number = i + 1;
            SectionButton button = new SectionButton(number);
            button.addActionListener(e -> showSection(number));
            group.add(button);
            sectionButtons[i] = button;
            grid.add(button);
        }
        // keep the grid top-aligned so the buttons stay 44px high
        JPanel gridHolder = new JPanel(new BorderLayout());
        gridHolder.setOpaque(false);
        gridHolder.add(grid, BorderLayout.NORTH);
        navigator.add(gridHolder, BorderLayout.CENTER);

        JPanel pager = new JPanel(new GridLayout(1, 2, 8, 0));
        pager.setOpaque(false);
        pager.setBorder(new EmptyBorder(12, 0, 0, 0));
        previousButton = new PillButton("‹ 上一段");
        previousButton.addActionListener(e -> showSection(section - 1));
        nextButton = new PillButton("下一段 ›");
        nextButton.addActionListener(e -> showSection(section + 1));
        pager.add(previousButton);
        pager.add(nextButton);
        navigator.add(pager, BorderLayout.SOUTH);
        return navigator;
    }

    private JComponent buildChineseCard() {
        Card card = new Card();
        card.setLayout(new BorderLayout(0, 10));
        card.setBorder(new EmptyBorder(18, 22, 18, 22));
        chineseHeading = new JLabel();
        chineseHeading.setFont(HEADING_FONT);
        chineseHeading.setForeground(ACCENT);
        card.add(chineseHeading, BorderLayout.NORTH);
        chinesePane = createPane();
        chineseScroll = wrapScroll(chinesePane);
        card.add(chineseScroll, BorderLayout.CENTER);
        return card;
    }

    private JComponent buildEnglishCard() {
        Card card = new Card();
        card.setLayout(new BorderLayout(0, 10));
        card.setBorder(new EmptyBorder(18, 22, 18, 22));
        card.setPreferredSize(new Dimension(430, 0));
        englishHeading = new JLabel();
        englishHeading.setFont(HEADING_FONT);
        englishHeading.setForeground(ACCENT);
        card.add(englishHeading, BorderLayout.NORTH);
        englishPane = createPane();
        englishScroll = wrapScroll(englishPane);
        card.add(englishScroll, BorderLayout.CENTER);
        return card;
    }

    private JComponent buildFooter() {
        JPanel footer = new JPanel(new BorderLayout());
        footer.setOpaque(false);
        footer.setBorder(new EmptyBorder(12, 4, 0, 4));

        JPanel separator = new JPanel();
        separator.setBackground(LINE);
        separator.setPreferredSize(new Dimension(0, 1));
        footer.add(separator, BorderLayout.NORTH);

        progressLabel = new JLabel();
        progressLabel.setFont(FOOTER_FONT);
        progressLabel.setForeground(SOFT_INK);
        progressLabel.setBorder(new EmptyBorder(8, 0, 0, 0));
        footer.add(progressLabel, BorderLayout.CENTER);
        footer.add(buildSourceLink(), BorderLayout.EAST);
        return footer;
    }

    private JComponent buildSourceLink() {
        JLabel link = new JLabel("資料來源 · ctext.org ↗");
        link.setFont(LINK_FONT);
        link.setForeground(SOFT_INK);
        link.setBorder(new EmptyBorder(8, 0, 0, 0));
        link.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
        link.setToolTipText("在瀏覽器中開啟 " + SOURCE_URL);
        link.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseEntered(MouseEvent e) {
                link.setForeground(ACCENT);
            }

            @Override
            public void mouseExited(MouseEvent e) {
                link.setForeground(SOFT_INK);
            }

            @Override
            public void mouseClicked(MouseEvent e) {
                openSourceUrl();
            }
        });
        return link;
    }

    private static void openSourceUrl() {
        try {
            if (Desktop.isDesktopSupported()
                    && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
                Desktop.getDesktop().browse(URI.create(SOURCE_URL));
                return;
            }
        } catch (IOException | RuntimeException ex) {
            // fall back to showing the URL below
        }
        JOptionPane.showMessageDialog(null, SOURCE_URL, "資料來源",
                JOptionPane.INFORMATION_MESSAGE);
    }

    private static JTextPane createPane() {
        JTextPane pane = new JTextPane();
        pane.setEditable(false);
        pane.setFocusable(false);
        pane.setOpaque(false);
        pane.setBorder(null);
        return pane;
    }

    private static JScrollPane wrapScroll(JTextPane pane) {
        JScrollPane scroll = new JScrollPane(pane);
        scroll.setBorder(null);
        scroll.setOpaque(false);
        scroll.getViewport().setOpaque(false);
        scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
        scroll.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
        return scroll;
    }

    // ---- state changes --------------------------------------------------------

    private void showSection(int number) {
        section = clamp(number);
        int index = section - 1;

        fillPane(chinesePane, CHINESE[index], CHINESE_FONT, 0.42f, INK);
        fillPane(englishPane, ENGLISH[index], ENGLISH_FONT, 0.28f, BODY_ENGLISH);
        chineseHeading.setText("原文 · 第 " + section + " 段");
        englishHeading.setText("英譯 · 第 " + section + " 段");

        for (int i = 0; i < SECTION_COUNT; i++) {
            sectionButtons[i].setSelected(i + 1 == section);
            sectionButtons[i].setEnabled(i + 1 != section);
        }
        previousButton.setEnabled(section > 1);
        nextButton.setEnabled(section < SECTION_COUNT);
        progressLabel.setText(String.format(
                "第 %d 段 / 共 %d 段 — ← → 切換段落,Home/End 跳至首尾",
                section, SECTION_COUNT));
        resetScroll();
    }

    private void resetScroll() {
        chineseScroll.getViewport().setViewPosition(new Point(0, 0));
        englishScroll.getViewport().setViewPosition(new Point(0, 0));
    }

    private void bindKeys(JComponent target) {
        bind(target, "LEFT", () -> showSection(section - 1));
        bind(target, "RIGHT", () -> showSection(section + 1));
        bind(target, "HOME", () -> showSection(1));
        bind(target, "END", () -> showSection(SECTION_COUNT));
    }

    private static void bind(JComponent target, String keyStroke, Runnable action) {
        target.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
                KeyStroke.getKeyStroke(keyStroke), keyStroke);
        target.getActionMap().put(keyStroke, new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent e) {
                action.run();
            }
        });
    }

    // ---- text rendering ---------------------------------------------------------

    private static void fillPane(JTextPane pane, String text, Font font,
            float lineSpacing, Color color) {
        pane.setText(text);
        StyledDocument document = pane.getStyledDocument();
        SimpleAttributeSet attributes = new SimpleAttributeSet();
        StyleConstants.setFontFamily(attributes, font.getFamily());
        StyleConstants.setFontSize(attributes, font.getSize());
        StyleConstants.setForeground(attributes, color);
        StyleConstants.setLineSpacing(attributes, lineSpacing);
        int length = document.getLength();
        document.setParagraphAttributes(0, length, attributes, false);
        document.setCharacterAttributes(0, length, attributes, false);
        pane.setCaretPosition(0);
    }

    private static Font chooseFont(List<String> families, int style, int size) {
        String[] available = GraphicsEnvironment.getLocalGraphicsEnvironment()
                .getAvailableFontFamilyNames();
        for (String family : families) {
            for (String name : available) {
                if (name.equalsIgnoreCase(family)) {
                    return new Font(name, style, size);
                }
            }
        }
        return new Font(Font.SERIF, style, size);
    }

    private static int clamp(int number) {
        return Math.max(1, Math.min(SECTION_COUNT, number));
    }

    // ---- offscreen preview ------------------------------------------------------

    private static void renderPreview(String output, int startSection) throws IOException {
        int width = 1240;
        int height = 760;
        SunziReaderGui reader = new SunziReaderGui(startSection);
        JPanel panel = reader.buildPanel();
        reader.showSection(startSection);
        panel.setSize(new Dimension(width, height));
        layoutAll(panel);
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = image.createGraphics();
        g.setColor(PAPER);
        g.fillRect(0, 0, width, height);
        panel.printAll(g);
        g.dispose();
        ImageIO.write(image, "png", Path.of(output).toFile());
    }

    private static void layoutAll(Container container) {
        container.doLayout();
        for (Component child : container.getComponents()) {
            if (child instanceof Container nested) {
                layoutAll(nested);
            }
        }
    }

    // ---- custom controls --------------------------------------------------------

    /** Rounded "book page" card with a 1px outline. */
    private static final class Card extends JPanel {
        private static final long serialVersionUID = 1L;

        Card() {
            setOpaque(false);
        }

        @Override
        protected void paintComponent(Graphics graphics) {
            Graphics2D g = (Graphics2D) graphics.create();
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);
            g.setColor(CARD);
            g.fillRoundRect(0, 0, getWidth() - 1, getHeight() - 1, 16, 16);
            g.setColor(LINE);
            g.setStroke(new BasicStroke(1f));
            g.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, 16, 16);
            g.dispose();
            super.paintComponent(graphics);
        }
    }

    /** Numbered section button; the current section stays selected and disabled. */
    private static final class SectionButton extends JToggleButton {
        private static final long serialVersionUID = 1L;
        private boolean hover;

        SectionButton(int number) {
            super(String.valueOf(number));
            setFont(BUTTON_FONT);
            setFocusPainted(false);
            setContentAreaFilled(false);
            setBorderPainted(false);
            setOpaque(false);
            setRolloverEnabled(false);
            setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
            addMouseListener(new MouseAdapter() {
                @Override
                public void mouseEntered(MouseEvent e) {
                    hover = true;
                    repaint();
                }

                @Override
                public void mouseExited(MouseEvent e) {
                    hover = false;
                    repaint();
                }
            });
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(44, 44);
        }

        @Override
        protected void paintComponent(Graphics graphics) {
            Graphics2D g = (Graphics2D) graphics.create();
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);
            Color fill;
            Color ink;
            if (isSelected()) {
                fill = ACCENT;
                ink = CREAM;
            } else if (hover && isEnabled()) {
                fill = BUTTON_HOVER;
                ink = INK;
            } else {
                fill = BUTTON_IDLE;
                ink = INK;
            }
            g.setColor(fill);
            g.fillRoundRect(0, 0, getWidth() - 1, getHeight() - 1, 10, 10);
            g.setColor(ink);
            g.setFont(getFont());
            FontMetrics metrics = g.getFontMetrics();
            String text = getText();
            int x = (getWidth() - metrics.stringWidth(text)) / 2;
            int y = (getHeight() - metrics.getHeight()) / 2 + metrics.getAscent();
            g.drawString(text, x, y);
            g.dispose();
        }
    }

    /** Capsule-shaped prev/next button; border and label fade when disabled. */
    private static final class PillButton extends JButton {
        private static final long serialVersionUID = 1L;
        private boolean hover;

        PillButton(String text) {
            super(text);
            setFont(PILL_FONT);
            setFocusPainted(false);
            setContentAreaFilled(false);
            setBorderPainted(false);
            setOpaque(false);
            setRolloverEnabled(false);
            setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
            addMouseListener(new MouseAdapter() {
                @Override
                public void mouseEntered(MouseEvent e) {
                    hover = true;
                    repaint();
                }

                @Override
                public void mouseExited(MouseEvent e) {
                    hover = false;
                    repaint();
                }
            });
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(90, 38);
        }

        @Override
        protected void paintComponent(Graphics graphics) {
            Graphics2D g = (Graphics2D) graphics.create();
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);
            boolean enabled = isEnabled();
            Color fill = enabled && hover ? PILL_HOVER : CARD;
            Color border = enabled ? (hover ? ACCENT : PILL_BORDER) : LINE;
            Color ink = enabled ? ACCENT : DISABLED_INK;
            g.setColor(fill);
            g.fillRoundRect(0, 0, getWidth() - 1, getHeight() - 1,
                    getHeight() - 1, getHeight() - 1);
            g.setColor(border);
            g.setStroke(new BasicStroke(1f));
            g.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1,
                    getHeight() - 1, getHeight() - 1);
            g.setColor(ink);
            g.setFont(getFont());
            FontMetrics metrics = g.getFontMetrics();
            String text = getText();
            int x = (getWidth() - metrics.stringWidth(text)) / 2;
            int y = (getHeight() - metrics.getHeight()) / 2 + metrics.getAscent();
            g.drawString(text, x, y);
            g.dispose();
        }
    }

    /** Small cinnabar seal with two characters set vertically. */
    private static final class SealLabel extends JComponent {
        private static final long serialVersionUID = 1L;
        private final String top;
        private final String bottom;

        SealLabel(String top, String bottom) {
            this.top = top;
            this.bottom = bottom;
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(52, 52);
        }

        @Override
        protected void paintComponent(Graphics graphics) {
            Graphics2D g = (Graphics2D) graphics.create();
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);
            int width = getWidth();
            int height = getHeight();
            g.setColor(ACCENT);
            g.fillRoundRect(0, 0, width - 1, height - 1, 8, 8);
            g.setColor(CREAM);
            g.setStroke(new BasicStroke(1f));
            g.drawRoundRect(3, 3, width - 8, height - 8, 5, 5);
            g.setFont(SEAL_FONT);
            FontMetrics metrics = g.getFontMetrics();
            int lineHeight = metrics.getAscent() + metrics.getDescent();
            int startY = (height - lineHeight * 2) / 2 + metrics.getAscent();
            g.drawString(top, (width - metrics.stringWidth(top)) / 2, startY);
            g.drawString(bottom, (width - metrics.stringWidth(bottom)) / 2,
                    startY + lineHeight);
            g.dispose();
        }
    }
}