用一个 java 文件做出能翻页的《論語·學而》中英对照本

46 阅读19分钟

我在掘金上写过 用 Tkinter 实现简单的论语第一章阅读器 一文,我把它移植成了 Java 版:左侧 4×4 节号按钮,中英双栏对照,宣纸底色配朱砂印章,键盘翻页,还能不弹窗直接截图。只要一个 Java 文件,JDK 自带的东西就够用。

背景

用 Tkinter 实现简单的论语第一章阅读器 一文,只处理了第一章《學而》。交互是轻量级的:左边 4×4 的数字按钮对应十六节,中间繁体原文、右边英译,点按钮或翻页时两栏一起换,当前小节的按钮高亮且点不动。

界面本身不难,难的是在不装任何东西的前提下做出同样的手感:比例分栏、互斥的状态按钮、长文本自动换行。于是有了这个 Java 版——只用 JDK 自带的能力,一个 Java 文件就能跑。

不用 JavaFX 或 Electron,是因为我们的目标是"单文件就能运行":JDK 自带、不用装运行时、单文件发给别人就能用。

image.png

Java 版重写了版式(宣纸底色、圆角书页卡片、朱砂印章),按钮与印章全部自绘、不跟系统主题走,另加了键盘导航和"不弹窗直接出图"。

原版(Tkinter)本实现(Swing)
pack / grid 布局BorderLayout 三栏 + GridLayout(4, 4)
当前项 state = disabled自绘 JToggleButton + ButtonGroup
可自动换行的 TextJTextPane + StyledDocument

效果

同一个小节在中英两栏同时切换,下面的选中态和边界禁用态最能说明状态机是否写对。

image.png

image.png

两处细节值得留意:当前小节的数字是朱砂底、奶油字,同时点不动;走到第 16 节,「下一節」自动变灰,第 1 节的「上一節」同理。

如何运行

javac -d /tmp/analects-build AnalectsReaderGui.java
java -cp /tmp/analects-build AnalectsReaderGui
java -cp /tmp/analects-build AnalectsReaderGui --section 15
java -Djava.awt.headless=true -cp /tmp/analects-build AnalectsReaderGui --preview out.png 8

十六节原文与 Legge(1893)的英译都写在源码里的两个 String[] 中,下标即"小节号减一";编译产物 8 个 class 全部来自这一个文件(4 个自绘控件 + 几个匿名监听器)。

三个设计判断

API 细节这里不展开(文末有完整代码,另附「实现细节」给想深挖的读者),只说三个真正影响成品效果的判断。

1)所有联动都收在一个 showSection

private void showSection(int number) {
    int target = clamp(number);
    if (rendered && target == section) {
        return;                       // 已经在这一节:连重绘都不做
    }
    rendered = true;
    section = target;
    int index = section - 1;

    fillPane(chinesePane, CHINESE[index], CHINESE_FONT, 0.42f, INK);
    fillPane(englishPane, ENGLISH[index], ENGLISH_FONT, 0.28f, BODY_ENGLISH);
    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 節 · 原文 %d 字",
            section, SECTION_COUNT, hanCount(CHINESE[index])));
}

正文、标题、按钮态、翻页边界、页脚字数、滚动位置,六处状态一次写完,就不会出现"切了一半"的中间态。开头那段提前返回是为修 bug 加的,原因见下面的坑。

2)按钮必须自绘

这是和原作差别最大的一处。Tkinter 版里当前小节"高亮 + 禁用",而 Swing 的禁用态会覆盖选中配色,直接用默认 JToggleButton 就看不出当前在哪一节。所以关掉 LAF 的填充、边框与 hover,自己按三态画:

setContentAreaFilled(false);
setBorderPainted(false);
setFocusPainted(false);
setFocusable(false);            // 纯自绘控件不需要系统焦点

if (selected) { fill = ACCENT; ink = CREAM; }               // 朱砂底、奶油字
else if (hover && isEnabled()) { fill = BUTTON_HOVER; ink = INK; }
else { fill = BUTTON_IDLE; ink = INK; }

判断顺序是"选中优先于可用",所以"高亮但点不动"能原样保留;禁用只影响 hover,不影响配色。

3)不弹窗也能出图

文中的三张截图没有一张是手动截的,都是程序自己画的:

JPanel panel = reader.buildPanel();
panel.setSize(new Dimension(1240, 760));
layoutAll(panel);                  // 未显示的容器不会自动布局

BufferedImage image = new BufferedImage(1240, 760, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(PAPER);
g.fillRect(0, 0, 1240, 760);
panel.printAll(g);                 // 整个界面画进图片
g.dispose();
ImageIO.write(image, "png", Path.of(output).toFile());

配合 -Djava.awt.headless=true,没有显示器的 CI 或命令行环境也能出图。

踩过的坑

坑一:在第 16 节按 →,内容没变,第 1 个按钮却亮了。

复现只要两步:点第 16 个数字按钮,再按 →。

两个问题叠在一起:一是点过按钮后焦点留在那个 JToggleButton 上,而 showSection 会把当前按钮 setEnabled(false),焦点失去宿主后被 Swing 交给下一个可聚焦组件,绕一圈正好回到第 1 个按钮;二是越界时 showSection(17) 被 clamp 成 16,却仍走完"改文字、切选中、归零滚动"一整套。

改法就是前面那两处:setFocusable(false) 让按钮不抢焦点,showSection 提前返回让同一节不重绘。验证不走 Robot(会打扰系统),而是把 KEY_PRESSED 直接投给内容面板,再反射读 section 与 16 个按钮的 isSelected()。

坑二:预览模式一启动就 HeadlessException。

最初写成了字段初始化 private final JFrame frame = new JFrame(...),headless 下类一加载就崩。改成 private JFrame frame;、只在 show() 里创建,预览路径永远不碰 JFrame。

小结

一次"用 JDK 自带能力做完一个小工具"的练习,留下三条经验:

  • 布局全交给管理器,没有一处 setBounds;窗口从 1040×640 拉到 1600×900,只有卡片变宽变高,圆角与间距不变
  • 用 paintComponent 把按钮、卡片、印章统一成一套设计语言,外观不随 LAF 漂移
  • 用离屏渲染把界面变成可复现的图片,写文章时省掉手动截屏

继续做的话方向不少:数据外置(换《為政》只换数据)、目录侧栏显示每节首句、中英逐句对齐、朗读或注音。

最后照例说明:这个界面的代码,以及文中涉及的知识点,都是我借助 trae 汇总的——第一版实现与 API 用法由它给出,我再改版式、理交互,并在本机 JDK 21 上编译与渲染截图核对。发现写错或能更简洁的地方,欢迎在评论区拍我。

参考资料

附:实现细节

正文省略的部分,留给想深挖的读者。

  • 分栏:外层 BorderLayout + EmptyBorder(20, 24, 16, 24),三栏用 BorderLayout(16, 0);左栏固定 212px、右栏固定 430px,中间那栏吃掉剩余宽度,所以窗口拉宽时只有原文栏变宽。
  • JTextPane 而非 JTextArea:后者调不了行距,换行只能靠 lineWrap 开关;StyledDocument 的 setParagraphAttributes(段落)与 setCharacterAttributes(字符)必须两边都设才生效。
  • 字号配比:中文 21pt 宋体、行距 0.42f,英文 15pt Georgia、行距 0.28f——中文行少而重、英文行多而轻,两栏才平衡。
  • 字族回退:楷体在 macOS 叫 Kaiti SC,Linux 上多为 LXGW WenKai、Noto Serif CJK;给一串候选,运行时用 GraphicsEnvironment 取第一个命中的,都没有就退到 Font.SERIF。
  • 键盘:InputMap 用 WHEN_IN_FOCUSED_WINDOW 作用域 + ActionMap 匿名 AbstractAction,与按钮共用同一个 showSection,越界由 clamp 兜住。
  • 汉字计数:Character.UnicodeScript.HAN 按码点遍历(codePointAt + charCount),标点不计入,扩展区生僻字也不会被算成两个。
  • 抗锯齿:自绘控件都开 KEY_ANTIALIASING,否则圆角毛边;先 graphics.create()、画完 dispose(),不污染外部状态。
  • 序列化警告:-Xlint:all 会给 Card / SectionButton / PillButton / SealLabel 报"未声明 serialVersionUID",各补一行 private static final long serialVersionUID = 1L; 即可。
  • 印章:46×46 朱砂方块内嵌浅色细框,两字用 FontMetrics 的 ascent/descent 算纵向间距,换字体不错位。

完整代码


import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.Insets;
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.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import javax.imageio.ImageIO;
import javax.swing.AbstractAction;
import javax.swing.Box;
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.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextPane;
import javax.swing.JToggleButton;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
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 book of the Analects (《論語·學而》), built with
 * plain Swing - the Java counterpart of the Tkinter example in
 * <a href="https://juejin.cn/post/7650532520364671017">this article</a>.
 * <p>
 * Layout: the left column holds a 4x4 grid of the 16 section numbers plus
 * previous/next buttons, the center column shows the traditional Chinese
 * original, and the right column shows James Legge's English translation.
 * The current section button is selected and disabled, and prev/next are
 * disabled at the ends, mirroring the Tkinter version. Arrow keys move
 * between sections as well.
 * <p>
 * Run with:
 * javac AnalectsReaderGui.java
 * java AnalectsReaderGui
 * <p>
 * Options:
 * java AnalectsReaderGui --section 15          open at section 15
 * java AnalectsReaderGui --preview out.png     render the window offscreen
 * java AnalectsReaderGui --preview out.png 15  render section 15 offscreen
 */
public class AnalectsReaderGui {

    private static final int SECTION_COUNT = 16;
    private static final int GRID_COLUMNS = 4;

    private static final String TITLE = "論語 · 學而";

    // ---- 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, 0xFD, 0xF7);
    private static final Color INK = new Color(0x2A, 0x25, 0x21);
    private static final Color BODY_ENGLISH = new Color(0x45, 0x3F, 0x36);
    private static final Color MUTED = new Color(0x85, 0x7B, 0x6C);
    private static final Color ACCENT = new Color(0x96, 0x3B, 0x2F);
    private static final Color LINE = new Color(0xE4, 0xDA, 0xC7);
    private static final Color BUTTON_IDLE = new Color(0xF1, 0xE9, 0xDA);
    private static final Color BUTTON_HOVER = new Color(0xEA, 0xDF, 0xCB);
    private static final Color CREAM = new Color(0xFF, 0xF8, 0xEC);
    private static final Color DISABLED_INK = new Color(0xB5, 0xAC, 0x9E);
    private static final Color PILL_HOVER = new Color(0xF5, 0xE7, 0xE1);

    // ---- fonts (fall back to logical fonts when a family is absent) ------

    private static final Font TITLE_FONT =
            chooseFont(List.of("Kaiti SC", "LXGW WenKai", "Songti SC", "STSong"), Font.BOLD, 30);
    private static final Font CHINESE_FONT =
            chooseFont(List.of("Songti SC", "STSong", "Songti TC"), Font.PLAIN, 21);
    private static final Font ENGLISH_FONT =
            chooseFont(List.of("Georgia", "Palatino", "Times New Roman"), Font.PLAIN, 15);
    private static final Font LABEL_FONT =
            chooseFont(List.of("PingFang SC", "Songti SC", "STSong"), Font.BOLD, 12);
    private static final Font UI_FONT =
            chooseFont(List.of("PingFang SC", "Heiti SC", "Helvetica"), Font.PLAIN, 11);
    private static final Font BUTTON_FONT =
            chooseFont(List.of("PingFang SC", "Heiti SC", "Songti SC"), Font.BOLD, 12);
    private static final Font NUMBER_FONT =
            chooseFont(List.of("Georgia", "Palatino", "Times New Roman"), Font.BOLD, 14);
    private static final Font SEAL_FONT =
            chooseFont(List.of("Kaiti SC", "Songti SC", "STSong"), Font.BOLD, 15);

    // ---- text: 學而, 16 sections, traditional Chinese + Legge -------------

    private static final String[] CHINESE = {
            "子曰:「學而時習之,不亦說乎?有朋自遠方來,不亦樂乎?人不知而不慍,不亦君子乎?」",
            "有子曰:「其為人也孝弟,而好犯上者,鮮矣;不好犯上,而好作亂者,未之有也。君子務本,本立而道生。孝弟也者,其為仁之本與!」",
            "子曰:「巧言令色,鮮矣仁!」",
            "曾子曰:「吾日三省吾身:為人謀而不忠乎?與朋友交而不信乎?傳不習乎?」",
            "子曰:「道千乘之國:敬事而信,節用而愛人,使民以時。」",
            "子曰:「弟子入則孝,出則弟,謹而信,汎愛眾,而親仁。行有餘力,則以學文。」",
            "子夏曰:「賢賢易色,事父母能竭其力,事君能致其身,與朋友交言而有信。雖曰未學,吾必謂之學矣。」",
            "子曰:「君子不重則不威,學則不固。主忠信,無友不如己者,過則勿憚改。」",
            "曾子曰:「慎終追遠,民德歸厚矣。」",
            "子禽問於子貢曰:「夫子至於是邦也,必聞其政,求之與?抑與之與?」子貢曰:「夫子溫、良、恭、儉、讓以得之。夫子之求之也,其諸異乎人之求之與?」",
            "子曰:「父在,觀其志;父沒,觀其行;三年無改於父之道,可謂孝矣。」",
            "有子曰:「禮之用,和為貴。先王之道斯為美,小大由之。有所不行,知和而和,不以禮節之,亦不可行也。」",
            "有子曰:「信近於義,言可復也;恭近於禮,遠恥辱也;因不失其親,亦可宗也。」",
            "子曰:「君子食無求飽,居無求安,敏於事而慎於言,就有道而正焉,可謂好學也已。」",
            "子貢曰:「貧而無諂,富而無驕,何如?」子曰:「可也。未若貧而樂,富而好禮者也。」子貢曰:「《詩》云:『如切如磋,如琢如磨。』其斯之謂與?」子曰:「賜也,始可與言詩已矣!告諸往而知來者。」",
            "子曰:「不患人之不己知,患不知人也。」",
    };

    private static final String[] ENGLISH = {
            """
            The Master said, "Is it not pleasant to learn with a constant perseverance and application? Is it not delightful to have friends coming from distant quarters? Is he not a man of complete virtue, who feels no discomposure though men may take no note of him?"\
            """,
            """
            The philosopher You said, "They are few who, being filial and fraternal, are fond of offending against their superiors. There have been none, who, not liking to offend against their superiors, have been fond of stirring up confusion. The superior man bends his attention to what is radical. That being established, all practical courses naturally grow up. Filial piety and fraternal submission! - are they not the root of all benevolent actions?"\
            """,
            """
            The Master said, "Fine words and an insinuating appearance are seldom associated with true virtue."\
            """,
            """
            The philosopher Zeng said, "I daily examine myself on three points: whether, in transacting business for others, I may have been not faithful; whether, in intercourse with friends, I may have been not sincere; whether I may have not mastered and practiced the instructions of my teacher."\
            """,
            """
            The Master said, "To rule a country of a thousand chariots, there must be reverent attention to business, and sincerity; economy in expenditure, and love for men; and the employment of the people at the proper seasons."\
            """,
            """
            The Master said, "A youth, when at home, should be filial, and, abroad, respectful to his elders. He should be earnest and truthful. He should overflow in love to all, and cultivate the friendship of the good. When he has time and opportunity, after the performance of these things, he should employ them in polite studies."\
            """,
            """
            Zi Xia said, "If a man withdraws his mind from the love of beauty, and applies it as sincerely to the love of the virtuous; if, in serving his parents, he can exert his utmost strength; if, in serving his prince, he can devote his life; if, in his intercourse with his friends, his words are sincere - although men say that he has not learned, I will certainly say that he has."\
            """,
            """
            The Master said, "If the scholar be not grave, he will not call forth any veneration, and his learning will not be solid. Hold faithfulness and sincerity as first principles. Have no friends not equal to yourself. When you have faults, do not fear to abandon them."\
            """,
            """
            The philosopher Zeng said, "Let there be a careful attention to perform the funeral rites to parents, and let them be followed when long gone with the ceremonies of sacrifice - then the virtue of the people will resume its proper excellence."\
            """,
            """
            Zi Qin asked Zi Gong, saying, "When our master comes to any country, he does not fail to learn all about its government. Does he ask his information? or is it given to him?" Zi Gong said, "Our master is benign, upright, courteous, temperate, and complaisant and thus he gets his information. The master's mode of asking information! - is it not different from that of other men?"\
            """,
            """
            The Master said, "While a man's father is alive, look at the bent of his will; when his father is dead, look at his conduct. If for three years he does not alter from the way of his father, he may be called filial."\
            """,
            """
            The philosopher You said, "In practicing the rules of propriety, a natural ease is to be prized. In the ways prescribed by the ancient kings, this is the excellent quality, and in things small and great we follow them. Yet it is not to be observed in all cases. If one, knowing how such ease should be prized, manifests it, without regulating it by the rules of propriety, this likewise is not to be done."\
            """,
            """
            The philosopher You said, "When agreements are made according to what is right, what is spoken can be made good. When respect is shown according to what is proper, one keeps far from shame and disgrace. When the parties upon whom a man leans are proper persons to be intimate with, he can make them his guides and masters."\
            """,
            """
            The Master said, "He who aims to be a man of complete virtue in his food does not seek to gratify his appetite, nor in his dwelling place does he seek the appliances of ease; he is earnest in what he is doing, and careful in his speech; he frequents the company of men of principle that he may be rectified - such a person may be said indeed to love to learn."\
            """,
            """
            Zi Gong said, "What do you pronounce concerning the poor man who yet does not flatter, and the rich man who is not proud?" The Master replied, "They will do; but they are not equal to him, who, though poor, is yet cheerful, and to him, who, though rich, loves the rules of propriety." Zi Gong replied, "It is said in the Book of Poetry, 'As you cut and then file, as you carve and then polish.' - The meaning is the same, I apprehend, as that which you have just expressed." The Master said, "With one like Ci, I can begin to talk about the odes. I told him one point, and he knew its proper sequence."\
            """,
            """
            The Master said, "I will not be afflicted at men's not knowing me; I will be afflicted that I do not know men."\
            """,
    };

    private JFrame frame;
    private final JToggleButton[] sectionButtons = new JToggleButton[SECTION_COUNT];
    private final JTextPane chinesePane = new JTextPane();
    private final JTextPane englishPane = new JTextPane();

    private JButton previousButton;
    private JButton nextButton;
    private JScrollPane chineseScroll;
    private JScrollPane englishScroll;
    private JLabel chineseHeading;
    private JLabel englishHeading;
    private JLabel progressLabel;

    private int section;
    /** False until the panes hold text, so the first showSection() always renders. */
    private boolean rendered;

    public AnalectsReaderGui(int startSection) {
        section = clamp(startSection);
        stylePane(chinesePane, CHINESE_FONT);
        stylePane(englishPane, ENGLISH_FONT);
    }

    public static void main(String[] args) throws IOException {
        if (args.length >= 2 && args[0].equals("--preview")) {
            int previewSection = args.length >= 3 ? Integer.parseInt(args[2]) : 1;
            writePreview(args[1], previewSection);
            return;
        }
        int start = 1;
        if (args.length >= 2 && args[0].equals("--section")) {
            start = Integer.parseInt(args[1]);
        }
        int initial = start;
        SwingUtilities.invokeLater(() -> {
            useSystemLookAndFeel();
            new AnalectsReaderGui(initial).show();
        });
    }

    private static void useSystemLookAndFeel() {
        try {
            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
        } catch (Exception ignored) {
            // keep the default look and feel
        }
    }

    private void show() {
        frame = new JFrame(TITLE + " · 閱讀器");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setContentPane(buildPanel());
        frame.setSize(1240, 760);
        frame.setMinimumSize(new Dimension(1040, 640));
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
        installKeyBindings(frame.getRootPane());
        SwingUtilities.invokeLater(() -> {
            resetScroll();
            frame.getRootPane().requestFocusInWindow();
        });
    }

    // ---- layout ----------------------------------------------------------

    private JPanel buildPanel() {
        JPanel panel = new JPanel(new BorderLayout(0, 0));
        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);
        installKeyBindings(panel);
        showSection(section);
        return panel;
    }

    private JPanel buildHeader() {
        JPanel header = new JPanel(new BorderLayout(18, 0));
        header.setOpaque(false);
        header.setBorder(new EmptyBorder(0, 2, 18, 2));

        JPanel titles = new JPanel();
        titles.setLayout(new BoxLayout(titles, BoxLayout.Y_AXIS));
        titles.setOpaque(false);

        JLabel title = new JLabel(TITLE);
        title.setFont(TITLE_FONT);
        title.setForeground(INK);
        title.setAlignmentX(Component.LEFT_ALIGNMENT);

        JLabel subtitle = new JLabel("The Analects · Book I · 學而篇 十六節");
        subtitle.setFont(UI_FONT);
        subtitle.setForeground(MUTED);
        subtitle.setAlignmentX(Component.LEFT_ALIGNMENT);

        titles.add(title);
        titles.add(Box.createVerticalStrut(6));
        titles.add(subtitle);
        header.add(titles, BorderLayout.CENTER);
        header.add(new SealLabel("學而"), BorderLayout.EAST);
        return header;
    }

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

        JPanel heading = new JPanel();
        heading.setLayout(new BoxLayout(heading, BoxLayout.Y_AXIS));
        heading.setOpaque(false);
        JLabel label = new JLabel("章  節");
        label.setFont(LABEL_FONT);
        label.setForeground(ACCENT);
        label.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel hint = new JLabel("《學而》共十六節");
        hint.setFont(UI_FONT);
        hint.setForeground(MUTED);
        hint.setAlignmentX(Component.LEFT_ALIGNMENT);
        heading.add(label);
        heading.add(Box.createVerticalStrut(4));
        heading.add(hint);
        navigator.add(heading, BorderLayout.NORTH);

        JPanel grid = new JPanel(new GridLayout(GRID_COLUMNS, GRID_COLUMNS, 8, 8));
        grid.setOpaque(false);
        ButtonGroup group = new ButtonGroup();
        for (int i = 0; i < SECTION_COUNT; i++) {
            int number = i + 1;
            SectionButton button = new SectionButton(number);
            button.addActionListener(e -> showSection(number));
            group.add(button);
            sectionButtons[i] = button;
            grid.add(button);
        }
        JPanel gridBox = new JPanel(new BorderLayout());
        gridBox.setOpaque(false);
        gridBox.add(grid, BorderLayout.NORTH);
        navigator.add(gridBox, BorderLayout.CENTER);

        JPanel navButtons = new JPanel(new GridLayout(1, 2, 8, 0));
        navButtons.setOpaque(false);
        previousButton = new PillButton("‹ 上一節");
        nextButton = new PillButton("下一節 ›");
        previousButton.setToolTipText("上一節(←)");
        nextButton.setToolTipText("下一節(→)");
        previousButton.addActionListener(e -> showSection(section - 1));
        nextButton.addActionListener(e -> showSection(section + 1));
        navButtons.add(previousButton);
        navButtons.add(nextButton);
        navigator.add(navButtons, BorderLayout.SOUTH);
        return navigator;
    }

    private JPanel buildChineseCard() {
        Card card = new Card();
        card.setBorder(new EmptyBorder(16, 20, 16, 20));

        chineseHeading = new JLabel();
        chineseHeading.setFont(LABEL_FONT);
        chineseHeading.setForeground(ACCENT);
        JLabel tag = new JLabel("繁體原文");
        tag.setFont(UI_FONT);
        tag.setForeground(MUTED);

        JPanel headRow = new JPanel(new BorderLayout());
        headRow.setOpaque(false);
        headRow.add(chineseHeading, BorderLayout.WEST);
        headRow.add(tag, BorderLayout.EAST);

        chineseScroll = scroll(chinesePane);
        card.add(headRow, BorderLayout.NORTH);
        card.add(chineseScroll, BorderLayout.CENTER);
        return card;
    }

    private JPanel buildEnglishCard() {
        Card card = new Card();
        card.setBorder(new EmptyBorder(16, 20, 16, 20));
        card.setPreferredSize(new Dimension(430, 0));

        englishHeading = new JLabel();
        englishHeading.setFont(LABEL_FONT);
        englishHeading.setForeground(ACCENT);
        JLabel tag = new JLabel("James Legge · 1893");
        tag.setFont(UI_FONT);
        tag.setForeground(MUTED);

        JPanel headRow = new JPanel(new BorderLayout());
        headRow.setOpaque(false);
        headRow.add(englishHeading, BorderLayout.WEST);
        headRow.add(tag, BorderLayout.EAST);

        englishScroll = scroll(englishPane);
        card.add(headRow, BorderLayout.NORTH);
        card.add(englishScroll, BorderLayout.CENTER);
        return card;
    }

    private JPanel buildFooter() {
        JPanel footer = new JPanel(new BorderLayout(0, 10));
        footer.setOpaque(false);
        footer.setBorder(new EmptyBorder(14, 2, 0, 2));

        JPanel rule = new JPanel();
        rule.setBackground(LINE);
        rule.setPreferredSize(new Dimension(0, 1));

        progressLabel = new JLabel();
        progressLabel.setFont(UI_FONT);
        progressLabel.setForeground(MUTED);

        JLabel keys = new JLabel("← / → 切換小節   ·   Home / End 首尾   ·   數字按鈕直達");
        keys.setFont(UI_FONT);
        keys.setForeground(MUTED);

        JPanel row = new JPanel(new BorderLayout());
        row.setOpaque(false);
        row.add(progressLabel, BorderLayout.WEST);
        row.add(keys, BorderLayout.EAST);

        footer.add(rule, BorderLayout.NORTH);
        footer.add(row, BorderLayout.CENTER);
        return footer;
    }

    private static JScrollPane scroll(JTextPane pane) {
        JScrollPane scroll = new JScrollPane(pane);
        scroll.setBorder(null);
        scroll.setOpaque(false);
        scroll.getViewport().setOpaque(false);
        scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
        scroll.getVerticalScrollBar().setUnitIncrement(16);
        return scroll;
    }

    // ---- data flow -------------------------------------------------------

    private void showSection(int number) {
        int target = clamp(number);
        if (rendered && target == section) {
            return;   // already there: keys at the ends do not even repaint
        }
        rendered = true;
        section = target;
        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("原 文 · 第 " + chineseNumber(section) + " 節");
        englishHeading.setText("英 譯 · 第 " + chineseNumber(section) + " 節");

        // like the Tkinter version: the current section button is selected and
        // disabled, the neighbors stay enabled
        for (int i = 0; i < SECTION_COUNT; i++) {
            sectionButtons[i].setSelected(i + 1 == section);
            sectionButtons[i].setEnabled(i + 1 != section);
        }
        if (previousButton != null) {
            previousButton.setEnabled(section > 1);
        }
        if (nextButton != null) {
            nextButton.setEnabled(section < SECTION_COUNT);
        }
        if (progressLabel != null) {
            progressLabel.setText(String.format("第 %d / %d 節 · 原文 %d 字",
                    section, SECTION_COUNT, hanCount(CHINESE[index])));
        }
        resetScroll();
    }

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

    private static void stylePane(JTextPane pane, Font font) {
        pane.setEditable(false);
        pane.setOpaque(false);
        pane.setFocusable(false);
        pane.setFont(font);
        pane.setBorder(null);
        pane.setMargin(new Insets(10, 0, 0, 0));
    }

    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 int clamp(int number) {
        return Math.max(1, Math.min(SECTION_COUNT, number));
    }

    private static String chineseNumber(int number) {
        String[] digits = {"一", "二", "三", "四", "五", "六", "七", "八", "九"};
        if (number <= 9) {
            return digits[number - 1];
        }
        if (number == 10) {
            return "十";
        }
        int tens = number / 10;
        int ones = number % 10;
        return (tens == 1 ? "十" : digits[tens - 1] + "十")
                + (ones == 0 ? "" : digits[ones - 1]);
    }

    /**
     * Counts Han characters only, so punctuation such as 「」、:? is not
     * included in the "原文 x 字" counter shown in the footer.
     */
    private static int hanCount(String text) {
        int count = 0;
        for (int i = 0; i < text.length(); ) {
            int codePoint = text.codePointAt(i);
            if (Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN) {
                count++;
            }
            i += Character.charCount(codePoint);
        }
        return count;
    }

    // ---- keyboard --------------------------------------------------------

    private void installKeyBindings(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();
            }
        });
    }

    // ---- small custom widgets -------------------------------------------

    /** A paper card with rounded corners and a hairline border. */
    private static final class Card extends JPanel {

        private static final long serialVersionUID = 1L;

        Card() {
            setLayout(new BorderLayout(0, 10));
            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);
        }
    }

    /** One of the 16 number buttons; painted, so it looks the same in any LAF. */
    private static final class SectionButton extends JToggleButton {

        private static final long serialVersionUID = 1L;

        private boolean hover;

        SectionButton(int number) {
            super(String.valueOf(number));
            setFont(NUMBER_FONT);
            setFocusPainted(false);
            setContentAreaFilled(false);
            setBorderPainted(false);
            setOpaque(false);
            setRolloverEnabled(false);
            setFocusable(false);   // keep the focus on the window, arrows stay ours
            setPreferredSize(new Dimension(44, 36));
            setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR));
            setToolTipText("前往第 " + number + " 節");
            addMouseListener(new MouseAdapter() {
                @Override
                public void mouseEntered(MouseEvent e) {
                    hover = true;
                    repaint();
                }

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

        @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();
            boolean selected = isSelected();
            Color fill;
            Color ink;
            if (selected) {
                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, width - 1, height - 1, 10, 10);
            if (!selected) {
                g.setColor(LINE);
                g.drawRoundRect(0, 0, width - 1, height - 1, 10, 10);
            }
            g.setFont(getFont());
            FontMetrics metrics = g.getFontMetrics();
            String text = getText();
            int x = (width - metrics.stringWidth(text)) / 2;
            int y = (height - metrics.getHeight()) / 2 + metrics.getAscent();
            g.setColor(ink);
            g.drawString(text, x, y);
            g.dispose();
        }
    }

    /** The previous / next pill button, also painted for a consistent look. */
    private static final class PillButton extends JButton {

        private static final long serialVersionUID = 1L;

        private boolean hover;

        PillButton(String text) {
            super(text);
            setFont(BUTTON_FONT);
            setFocusPainted(false);
            setContentAreaFilled(false);
            setBorderPainted(false);
            setOpaque(false);
            setRolloverEnabled(false);
            setFocusable(false);   // keep the focus on the window, arrows stay ours
            setPreferredSize(new Dimension(0, 34));
            setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.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
        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();
            boolean enabled = isEnabled();
            Color fill = enabled && hover ? PILL_HOVER : CARD;
            Color border = enabled ? (hover ? ACCENT : new Color(0xC9, 0xA7, 0x9F)) : LINE;
            Color ink = enabled ? ACCENT : DISABLED_INK;
            g.setColor(fill);
            g.fillRoundRect(0, 0, width - 1, height - 1, height - 1, height - 1);
            g.setColor(border);
            g.drawRoundRect(0, 0, width - 1, height - 1, height - 1, height - 1);
            g.setFont(getFont());
            FontMetrics metrics = g.getFontMetrics();
            String text = getText();
            int x = (width - metrics.stringWidth(text)) / 2;
            int y = (height - metrics.getHeight()) / 2 + metrics.getAscent();
            g.setColor(ink);
            g.drawString(text, x, y);
            g.dispose();
        }
    }

    /** A red seal with two characters stacked vertically. */
    private static final class SealLabel extends JLabel {

        private static final long serialVersionUID = 1L;

        private final String top;
        private final String bottom;

        SealLabel(String text) {
            top = text.substring(0, 1);
            bottom = text.substring(1, 2);
            setOpaque(false);
            setFont(SEAL_FONT);
            setPreferredSize(new Dimension(46, 46));
            setMinimumSize(new Dimension(46, 46));
            setMaximumSize(new Dimension(46, 46));
            setToolTipText("《論語》第一章:學而");
        }

        @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(4, 4, width - 9, height - 9, 5, 5);

            g.setFont(getFont());
            FontMetrics metrics = g.getFontMetrics();
            int lineHeight = metrics.getAscent() + metrics.getDescent();
            int startY = (height - lineHeight * 2) / 2 + metrics.getAscent();
            g.setColor(CREAM);
            g.drawString(top, (width - metrics.stringWidth(top)) / 2, startY);
            g.drawString(bottom, (width - metrics.stringWidth(bottom)) / 2, startY + lineHeight);
            g.dispose();
        }
    }

    private static Font chooseFont(List<String> families, int style, int size) {
        String[] available =
                java.awt.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);
    }

    // ---- preview: paint the same panel into a PNG ------------------------

    private static void writePreview(String output, int startSection) throws IOException {
        useSystemLookAndFeel();
        int width = 1240;
        int height = 760;
        AnalectsReaderGui reader = new AnalectsReaderGui(startSection);
        JPanel panel = reader.buildPanel();
        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());
        System.out.println("wrote " + output);
    }

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

参考资料