23. WritePad¶
Pad edits text. WritePad edits documents: fonts and sizes, bold and italic, indents and tab stops, lists, and above all pages. A letter has to fit on a sheet of A4 and look on paper the way it looked on the screen. And a document has to open in other people's word processors, so WritePad reads and writes their file formats.
amade-writepad is modelled on the small word processors that came with the desktops of the 1990s: a toolbar, a format bar, a ruler, and the pages on a dark desk. It is the biggest program so far, about 10,300 lines of Java and 4,000 lines of tests. This chapter shows the three parts that make it a word processor, the page view, the file formats and printing, and it moves printing into amade-commons, where Calendar has been waiting for it since chapter 21.
What you build¶

- Formatted text: font, size, bold, italic, underline, strike-through, colour, alignment.
- Real pages in A5, A4, Letter or Legal, portrait or landscape, with margins; zoom from 50 % to 300 %.
- A ruler with indent markers and tab stops (left, centre, right, decimal) to drag.
- Bullet and numbered lists in several levels.
- ODT, DOCX, RTF and plain text, opened and saved; old DOC files through LibreOffice when it is installed.
- Printing that puts the lines and page breaks on paper exactly where they are on the screen.
What you will learn¶
- How Swing's text components are built: document, elements, views and the editor kit.
- How to replace Swing's views to lay text out on pages.
- How to keep a layout in points and show it in pixels, at any zoom.
- How to read and write document formats with the JDK alone, and why that is possible.
- How to print through
java.awt.print, and what moves into amade-commons for it.
Swing's text model in five minutes¶
Pad used a JTextArea, which shows one font and has no idea of a page. WritePad uses a JTextPane, the styled text component of Swing. Like every Swing text component it has four parts, and a word processor has to know all of them:
| Part | Class | What it is |
|---|---|---|
| Document | StyledDocument (DefaultStyledDocument) |
The text and its attributes. The model: no pixels, no fonts on screen. |
| Elements | Element |
The structure of the document: a root, one element per paragraph, and inside each paragraph one element per run of equally formatted characters. Attributes (AttributeSet) hang on the elements. |
| Views | View (BoxView, ParagraphView, LabelView, ...) |
The layout: one view per element, which knows its size, where its lines break, and how it paints. |
| Editor kit | EditorKit (StyledEditorKit) |
The glue: it makes the document, and its view factory decides which view each element gets. |
The document is shared, the views are not. That is the opening: if you give a JTextPane an editor kit with a different view factory, the same document is laid out differently. Everything else (typing, the caret, selection, undo, copy and paste) keeps working, because it talks to the document and asks the views only where things are.
Text on pages¶
Swing's StyledEditorKit lays out a document as one endless sheet: the section view stacks the paragraphs, and each paragraph view breaks its text into lines as wide as the component. WritePad's kit keeps most of the views and replaces two:
private final class Factory implements ViewFactory {
@Override
public View create(Element element) {
String kind = element.getName();
if (kind == null) return new LabelView(element);
return switch (kind) {
case AbstractDocument.SectionElementName -> new PagedSectionView(element, layout);
case AbstractDocument.ParagraphElementName -> new PagedParagraphView(element, layout);
case AbstractDocument.ContentElementName -> WritepadDocument.isLineBreak(element.getAttributes())
? new LineBreakView(element) : new LabelView(element);
case StyleConstants.ComponentElementName -> new ComponentView(element);
case StyleConstants.IconElementName -> new IconView(element);
default -> new BoxView(element, View.Y_AXIS);
};
}
}
The section becomes a PagedSectionView, which stacks the paragraphs on pages, and every paragraph a PagedParagraphView, which never lets a line cross the bottom of a page. One small class knows where the pages are, PageLayout, and it answers one question for both: where does a line go?
/**
* Where a line of the given height that would start at y goes: unchanged if it fits on its page,
* otherwise at the top of the text of the next page. Lines starting in a margin or gap move to the next
* text area. A line taller than a whole page stays where it is, so that layout always terminates.
*/
public int place(int y, int height) {
int page = pageAt(y);
if (y < contentTop(page)) return contentTop(page);
if (y + height <= contentBottom(page)) return y;
if (y == contentTop(page)) return y;
return contentTop(page + 1);
}
place is pure arithmetic over the page size and the margins, so it is tested without Swing:
@Test
void lineCrossingTheBottomMarginMovesToNextPage() {
int y = layout.contentBottom(0) - 10;
assertEquals(layout.contentTop(1), layout.place(y, 14));
assertEquals(y, layout.place(y, 10));
}
Note the last rule in place: a line that starts at the top of a page stays there, even if it is taller than the page. Without that rule, a picture larger than a page would move to the next page, and the next, and the layout would never end. Every layout loop needs a case where it stops moving things.
A paragraph uses place for every line it has broken:
@Override
protected void layoutMajorAxis(int targetSpan, int axis, int[] offsets, int[] spans) {
int top = startY + getTopInset();
int offset = 0;
int moved = 0;
for (int i = 0; i < getViewCount(); i++) {
int span = (int) Math.ceil(getView(i).getPreferredSpan(Y_AXIS));
int placed = layout.place(top + offset, span);
moved += placed - (top + offset);
offsets[i] = placed - top;
spans[i] = span;
offset = offsets[i] + span;
}
pushed = moved;
laidOutHeight = getTopInset() + offset + getBottomInset();
}
layoutMajorAxis is where a BoxView decides the vertical position of its children, here the lines. WritePad lets Swing break the lines as usual, then walks over them and moves each one that would cross a page boundary. The space a moved line leaves behind (pushed) is added to the paragraph's height, so the paragraphs below move down too. For that, a paragraph has to know where on the page it starts, and the section tells it before it lays it out:
@Override
protected void layoutMajorAxis(int targetSpan, int axis, int[] offsets, int[] spans) {
int width = layout.contentWidth();
int y = layout.contentTop(0);
for (int i = 0; i < getViewCount(); i++) {
View child = getView(i);
int height;
if (child instanceof PagedParagraphView paragraph) {
paragraph.setStartY(y);
paragraph.setSize(width, 0);
height = paragraph.laidOutHeight();
} else {
height = (int) Math.ceil(child.getPreferredSpan(Y_AXIS));
y = layout.place(y, height);
}
offsets[i] = y;
spans[i] = height;
y += height;
}
int pages = layout.pagesUpTo(y);
if (pages != pageCount) {
pageCount = pages;
// The component has to grow or shrink; the parent asks for the preferred size again.
View parent = getParent();
if (parent != null) parent.preferenceChanged(this, true, true);
}
}
When the number of pages changes, the section asks its parent for a new size. That is how the scroll bar grows while you type.

Pitfall: Swing's view classes are not an API
ParagraphView, BoxView and their protected methods are documented, but they were written for Swing's own use, and their behaviour is defined mostly by their source code. WritePad overrides a handful of them (layoutMajorAxis, calculateMajorAxisRequirements, getFlowStart, nextTabStop, getViewAtPoint), and each override has a comment that says what Swing does there and why it is not enough. When you extend a framework through its protected methods, write those comments: the next reader has to understand two pieces of code, not one.
Points inside, pixels outside¶
A page of A4 is 595 × 842 points (1/72 inch). On a screen at 100 % it should be as big as the real sheet, which on a 96 dpi screen is 794 pixels wide; at 50 % half of that. Where should the zoom live?
If the views laid out in pixels, every zoom would break the lines differently: fonts do not scale exactly linearly in pixels, so a line that just fits at 100 % may not fit at 125 %. The page breaks would move with the zoom, and the printout would match none of them. So WritePad lays out in points, always, and only the section view translates:
@Override
public void paint(Graphics g, Shape allocation) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.transform(toScreen(allocation));
// Glyphs scaled from point sizes: without fractional metrics they snap to whole pixels and the
// spacing inside a line goes uneven.
g2.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
Rectangle clip = g2.getClipBounds();
for (int page = 0; page < pageCount; page++) {
Rectangle sheet = new Rectangle(PageLayout.GAP, layout.pageTop(page), layout.pageWidth(),
layout.pageHeight());
if (clip != null && !clip.intersects(sheet.x - 4, sheet.y - 4, sheet.width + 8, sheet.height + 8)) {
continue;
}
g2.setColor(PAGE_SHADOW);
g2.fillRect(sheet.x + 3, sheet.y + 3, sheet.width, sheet.height);
g2.setColor(PAPER);
g2.fill(sheet);
g2.setColor(PAGE_EDGE);
g2.setStroke(new BasicStroke(0.5f));
g2.draw(sheet);
paintFooter(g2, page);
}
super.paint(g2, pointsAllocation());
} finally {
g2.dispose();
}
}
toScreen is a scale (and the offset that centres the page in a wide window). Everything inside the paragraphs and lines is in points; everything outside, the component size, the caret, the mouse, in pixels. The same translation runs the other way in modelToView and viewToModel, so a click lands on the right character at every zoom.
Two rendering hints matter. Fractional metrics lets glyphs sit at fractions of a pixel; without it, every glyph of a scaled font snaps to whole pixels and the spaces in a line become uneven. Text antialiasing makes small text readable at 50 %.
Tabs measured from the margin¶
Tab stops are a good example of how much a word processor depends on getting the coordinates right. A tab stop at 4 cm is 4 cm from the left margin, in RTF, ODT and DOCX alike. Swing measures tabs from a position it only learns while painting, so before the first paint, lines could break with different tab widths than after it. PagedParagraphView overrides getFlowStart and nextTabStop to measure from the margin, in the same coordinates in which it paints, and leaves the rules (which stop comes next, how a decimal tab aligns) to TabStops, a small class without Swing that is tested on its own.
The ruler follows the same idea. RulerModel knows the markers, the stops and what a drag does to them; Ruler is the component that draws the model and passes on the mouse. The model has its own tests, which never open a window.
Lists that are drawn, not typed¶
A numbered list item in WritePad is a paragraph with three attributes: the id of its list, its level, and its format (1., a., i., •). The number itself is not in the text. It is computed when the paragraph is painted:
static Map<Element, String> compute(StyledDocument document) {
Map<Element, String> labels = new IdentityHashMap<>();
Map<Integer, int[]> counters = new HashMap<>();
Element root = document.getDefaultRootElement();
for (int i = 0; i < root.getElementCount(); i++) {
Element paragraph = root.getElement(i);
AttributeSet a = paragraph.getAttributes();
int list = ListAttributes.list(a);
if (list == 0) continue;
int level = ListAttributes.level(a);
int[] count = counters.computeIfAbsent(list, id -> new int[ListAttributes.MAX_LEVEL + 1]);
count[level]++;
for (int deeper = level + 1; deeper < count.length; deeper++) count[deeper] = 0;
labels.put(paragraph, ListAttributes.format(a).label(count[level], level));
}
return labels;
}
One pass over the paragraphs counts, per list and level, and a higher level starts the deeper ones again. Paragraphs between two items of a list do not interrupt it, which is how word processors number a list with a note in the middle. The labels are kept in a cache until the document changes, so painting a page does not count the whole document again.
Why not type the numbers? Because then deleting item 2 would leave "1., 3., 4.", and every file format stores lists as structure, not as text. A label that the program draws is always right. PagedParagraphView.paintLabel draws it in front of the first line, at the first-line indent, in the font of the paragraph's first character.
File formats¶
Every format is a DocumentFormat:
package eu.amadeos.apps.writepad.service.format;
import java.io.IOException;
import java.util.List;
/**
* A file format amade-writepad can open and save: ODT, DOCX, DOC, RTF, plain text.
*
* <p>{@link #read} builds a new document that nothing displays yet, so it may run on a background thread.
* {@link #write} reads a document that may be shown in the editor and must run on the event dispatch thread;
* it only produces bytes, the caller writes them to disk.
*/
public interface DocumentFormat {
/** Short name for messages and file filters, e.g. "Rich Text Format". */
String name();
/** File extensions in lower case without dot, the usual one first, e.g. {@code rtf}. */
List<String> extensions();
default String defaultExtension() {
return extensions().get(0);
}
/** False if saving loses formatting (plain text), so the user is asked first. */
boolean keepsFormatting();
/**
* True if the first bytes of a file show it is in this format, whatever its name; e.g. RTF files start
* with <code>{\rtf</code>. Formats without a signature answer false.
*/
default boolean recognizes(byte[] head) {
return false;
}
/**
* @throws IOException if the bytes are not a document of this format
*/
DocumentContent read(byte[] bytes) throws IOException;
byte[] write(DocumentContent content) throws IOException;
/**
* Turns what {@link #write} produced into the file's content. It runs on a background thread, so formats
* that hand the work to another program (DOC through LibreOffice) do that here; most formats are done.
*/
default byte[] finish(byte[] written) throws IOException {
return written;
}
/** False if files cannot be saved in this format here (DOC without LibreOffice). */
default boolean canWrite() {
return true;
}
}
Read the comments about threads: read builds a new document that nothing shows yet, so it may run in the background; write reads the document in the editor, so it runs on the EDT and only produces bytes. finish runs in the background again, for formats that hand the bytes to another program. Guice collects the formats with a multibinder, a set that every binding adds to:
// The formats files can be opened and saved in; the registry makes the first that keeps formatting
// (ODT) the default for new documents.
Multibinder<DocumentFormat> formats = Multibinder.newSetBinder(binder(), DocumentFormat.class);
formats.addBinding().to(OdtFormat.class);
formats.addBinding().to(DocxFormat.class);
formats.addBinding().to(DocFormat.class);
formats.addBinding().to(RtfFormat.class);
formats.addBinding().to(PlainTextFormat.class);
A new format is one class and one line here. The registry decides which format opens a file, and it trusts the content more than the name:
/**
* The format to open a file with: one that recognizes the content, else the one for the extension, else
* the first format that keeps no formatting (plain text), which opens anything that is text.
*/
public DocumentFormat forOpening(Path path, byte[] content) {
for (DocumentFormat format : formats) {
if (format.recognizes(content)) return format;
}
return forPath(path).orElseGet(() -> formats.stream().filter(f -> !f.keepsFormatting()).findFirst()
.orElse(defaultFormat()));
}
ZIP files with XML inside¶
ODT (OpenDocument) and DOCX (Office Open XML) look frightening and are not: both are ZIP files with a few XML files inside. WritePad reads and writes them with java.util.zip and StAX, the streaming XML reader and writer that are part of the JDK. An ODT file has one rule that is easy to miss:
private byte[] zip() throws IOException {
fonts.add(Documents.DEFAULT_FAMILY);
byte[] body = xml(this::content);
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(out)) {
// The mimetype comes first and uncompressed, so that its name and type can be read at fixed offsets.
byte[] mime = Odf.MIME_TYPE.getBytes(StandardCharsets.US_ASCII);
ZipEntry entry = new ZipEntry("mimetype");
entry.setMethod(ZipEntry.STORED);
entry.setSize(mime.length);
entry.setCompressedSize(mime.length);
CRC32 crc = new CRC32();
crc.update(mime);
entry.setCrc(crc.getValue());
zip.putNextEntry(entry);
zip.write(mime);
zip.closeEntry();
put(zip, "content.xml", body);
put(zip, "styles.xml", xml(this::styles));
put(zip, "meta.xml", xml(this::meta));
put(zip, "META-INF/manifest.xml", xml(this::manifest));
}
return out.toByteArray();
}
The first entry must be mimetype, uncompressed. Then its name and its content stand at fixed offsets in the file, and a program can recognise an ODT file from its first hundred bytes, without unpacking anything. WritePad's own recognition does exactly that:
/**
* A ZIP file whose first entry is {@code mimetype}, stored, with the type of ODF text. DOCX files are ZIP
* files too, so the type has to be checked.
*/
@Override
public boolean recognizes(byte[] head) {
byte[] name = "mimetype".getBytes(StandardCharsets.US_ASCII);
byte[] type = Odf.MIME_TYPE.getBytes(StandardCharsets.US_ASCII);
if (head.length < 30 + name.length + type.length) return false;
if (head[0] != 'P' || head[1] != 'K' || head[2] != 3 || head[3] != 4) return false;
int nameLength = (head[26] & 0xFF) | (head[27] & 0xFF) << 8;
int extraLength = (head[28] & 0xFF) | (head[29] & 0xFF) << 8;
if (nameLength != name.length || !startsWith(head, 30, name)) return false;
return startsWith(head, 30 + nameLength + extraLength, type);
}
A DOCX file is a ZIP too, but without the mimetype entry, which is why the check reads the name and the type instead of stopping at PK. And the test checks the bytes, not only that reading works:
@Test
void writesAPackageOtherProgramsRecognise() throws IOException, BadLocationException {
StyledDocument doc = Documents.newDocument();
doc.insertString(0, "Hallo", null);
byte[] odt = format.write(new DocumentContent(doc, DocumentSettings.DEFAULT));
assertTrue(format.recognizes(odt));
String head = new String(odt, 30, 8 + Odf.MIME_TYPE.length(), StandardCharsets.US_ASCII);
assertEquals("mimetype" + Odf.MIME_TYPE, head, "mimetype first and stored");
Map<String, byte[]> files = unzip(odt);
assertTrue(files.keySet().containsAll(java.util.List.of("content.xml", "styles.xml", "meta.xml",
"META-INF/manifest.xml")), files.keySet().toString());
assertTrue(new String(files.get("META-INF/manifest.xml"), StandardCharsets.UTF_8).contains(Odf.MIME_TYPE));
}
Note: why no document library
Java libraries exist for these formats, and they are big: the usual one for DOCX brings about 19 MB of jars, four times the size of the whole WritePad package, and a DOM of the entire document in memory. WritePad needs three XML files of a DOCX and four of an ODT, and StAX reads them as a stream. The decision and its alternative are written down in the repository's CONTRIBUTING.md, with the size, the date and what would change it. Write such notes for every dependency you decide against: in a year someone will ask again.
RTF is different: a text format of nested groups in curly braces with control words such as \b and \par. WritePad reads it with its own small tokenizer. The old binary DOC format is the one case where WritePad does not write its own code: it runs LibreOffice (soffice --headless --convert-to docx) in the background, with a profile of its own, and reads the DOCX. Running another program well, with a timeout, its output and its exit code, is a part that moves into amade-commons in chapter 31.
The tests for every format do the same three things: write a document and read it back (a round trip), read files that other programs wrote, and check the bytes against the rules of the format. The round trip alone is not enough: a writer and a reader that share a mistake pass it.
Saving, and a second Ctrl+S¶
Opening and saving follow Pad (chapter 10): read and parse in the background, write the bytes on the EDT from the document in the editor, put them on disk in the background, and remember which revision was saved:
private void save(Runnable then) {
// One write at a time: two writes of the same file could finish in either order, and the older
// text would win. The title keeps its "*", so the user can save again when this one is done.
if (busy) return;
if (file == null) saveAs(then);
else saveTo(file, format, then);
}
private void saveAs(Runnable then) {
if (busy) return;
Path suggested = file != null ? file
: Path.of(System.getProperty("user.home"), msg.get("document.untitled") + "."
+ documents.formats().defaultFormat().defaultExtension());
List<DocumentFormat> writable = documents.formats().formats().stream().filter(DocumentFormat::canWrite).toList();
view.chooseSavePath(suggested, writable, format).ifPresent(target -> {
if (!target.format().keepsFormatting() && !view.confirmLoseFormatting(target.format().name())) return;
saveTo(target.path(), target.format(), then);
});
}
private void saveTo(Path path, DocumentFormat target, Runnable then) {
byte[] bytes;
try {
bytes = target.write(new DocumentContent(view.document(), layout.settings()));
} catch (IOException | RuntimeException e) {
LOG.warn("Cannot convert the document to {}", target.name(), e);
view.showError(msg.format("error.save", path.getFileName(), message(e)));
return;
}
long writing = revision;
busy = true;
documents.write(path, target, bytes).whenCompleteAsync((ignored, failure) -> {
busy = false;
if (failure != null) {
Throwable cause = DocumentService.cause(failure);
LOG.warn("Cannot save {}", path, cause);
view.showError(msg.format("error.save", path.getFileName(), message(cause)));
return;
}
file = path;
format = target;
// Edits made while the file was being written are still unsaved.
savedRevision = writing;
updateTitle();
then.run();
}, ui);
}
The first line of save was missing when this chapter was written. Save As and the "save your changes?" question checked busy, but plain Save of a document that already had a file did not. Press Ctrl+S twice quickly, and two writes of the same file ran on two virtual threads. They could finish in either order, and the older text could be the one left on disk: the same race as in Calendar's saving (chapter 21). Calendar had to save every change, so it chains its saves. A word processor saves when the user says so, and the user sees whether the document is saved: the title keeps its *. So WritePad simply starts no second save while one is running. The test drives both saves through an executor that only queues its tasks:
@Test
void aSecondSaveWaitsUntilTheFirstIsWritten() throws Exception {
Path brief = file("Brief.rtf", RTF);
presenter.open(brief);
executor.runAll();
type("1");
presenter.save();
type("2");
presenter.save();
executor.runAll();
assertEquals("Hallo Welt1", text(brief), "only the first save was written");
assertTrue(presenter.isModified());
presenter.save();
executor.runAll();
assertEquals("Hallo Welt12", text(brief));
assertFalse(presenter.isModified());
}
Printing¶
Because the layout is in points, printing is the easy part. java.awt.print hands a program a Graphics2D per page, in points, with (0, 0) at the corner of the paper. WritePad paints each page with the same views as the screen, at zoom 1:
/**
* Paints one page without zoom and without the page decoration, with the top-left corner of the paper at
* (0, 0): for printing.
*/
public void paintPage(Graphics2D g, int page) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
g2.clipRect(0, 0, layout.pageWidth(), layout.pageHeight());
g2.translate(-PageLayout.GAP, -layout.pageTop(page));
paintFooter(g2, page);
super.paint(g2, pointsAllocation());
} finally {
g2.dispose();
}
}
@Override
public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) {
if (pageIndex < 0 || pageIndex >= pages) return NO_SUCH_PAGE;
// For a landscape format the printer graphics are already rotated: (0, 0) is the top left corner
// of the sheet as the reader holds it.
section.paintPage((Graphics2D) graphics, pageIndex);
return PAGE_EXISTS;
}
The lines on paper break where they broke on the screen, because they are the same lines. This is the payoff of the decision in "Points inside, pixels outside".
Printing moves into amade-commons¶
Calendar prints its reports (chapter 21), and WritePad prints documents. Their pages have nothing in common. Calendar paints grids of days, WritePad paints text views. But around the pages they had the same code twice:
- Paper sizes: Calendar's
paperFor(Locale)with A4 and Letter in points, WritePad'sPaperSizewith A5, A4, Letter and Legal. - A page format for a paper, portrait or landscape, with or without margins.
- What the printer is told: the paper and the job name, and not the orientation.
- Where the pages go: a PostScript file (Calendar), the system's print dialog (WritePad).
That is the part that moves, into the package eu.amadeos.commons.print. PaperSize comes from WritePad, with one addition from Calendar, the usual paper of the user's country:
/** The usual paper where the user lives: Letter in North America and a few other countries, else A4. */
public static PaperSize forLocale(Locale locale) {
return LETTER_COUNTRIES.contains(locale.getCountry()) ? LETTER : A4;
}
PrintJobs has the rest, as static methods, because none of them keeps state:
/**
* A page format for the paper.
*
* @param landscape true to turn the sheet; the paper stays in portrait and the printer turns the graphics
* @param margin the margin on every side, in points, outside which the pages do not draw; 0 when the
* pages lay out their own margins
*/
public static PageFormat pageFormat(PaperSize paper, boolean landscape, double margin) {
Objects.requireNonNull(paper, "paper");
double width = paper.width();
double height = paper.height();
if (margin < 0 || 2 * margin >= Math.min(width, height)) {
throw new IllegalArgumentException("Margin does not fit the paper: " + margin);
}
Paper sheet = new Paper();
sheet.setSize(width, height);
sheet.setImageableArea(margin, margin, width - 2 * margin, height - 2 * margin);
PageFormat format = new PageFormat();
format.setPaper(sheet);
format.setOrientation(landscape ? PageFormat.LANDSCAPE : PageFormat.PORTRAIT);
return format;
}
/**
* The paper and the job name for the printer. The orientation is deliberately not requested: the page
* format already turns landscape pages, and asking the printer as well turns them twice on some drivers.
*/
public static PrintRequestAttributeSet attributes(PaperSize paper, String jobName) {
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
attributes.add(paper.media());
attributes.add(new JobName(jobName, null));
return attributes;
}
The comment on attributes is a lesson WritePad learned the hard way: a PageFormat in landscape already turns the graphics, and some printer drivers turn the page again when they are also asked for landscape. A rule like that belongs in one place, next to a test.
PostScript without a printer¶
Calendar wrote its PostScript files with a PrinterJob and a Destination attribute. Moving the code into amade-commons meant writing a test for it there, and the test failed on the build machine with "No print service found": a PrinterJob needs a printer, even when it only writes a file. Calendar's own tests had skipped on such machines all along, so nobody had noticed that Print to File only worked where CUPS was installed.
The JDK has a second way to print: stream print services, which write a format into any OutputStream. One of them writes PostScript and needs no printer at all:
/**
* Writes the pages to a PostScript file through the JDK's own PostScript stream printer, so it works
* without any printer or print system installed.
*/
public static void writePostScript(Pageable pages, Path file, String jobName) throws PrinterException, IOException {
StreamPrintServiceFactory[] factories = StreamPrintServiceFactory.lookupStreamPrintServiceFactories(
DocFlavor.SERVICE_FORMATTED.PAGEABLE, POSTSCRIPT);
if (factories.length == 0) throw new PrinterException("This Java has no PostScript printer");
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(file))) {
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(factories[0].getPrintService(out));
job.setJobName(jobName);
job.setPageable(pages);
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
attributes.add(new JobName(jobName, null));
job.print(attributes);
}
}
Now Calendar prints to a file on any computer, and its tests run everywhere instead of skipping. A test that is skipped is a test that does not exist; when you write assumeTrue, check now and then where it is false.
Re-pinning the two programs¶
The move is the same as for Texts in chapter 22:
- amade-commons gets
print/PaperSize.java,print/PrintJobs.javaand their tests. - WritePad deletes its
PaperSizeand imports the one from amade-commons (the format readers and writers, the page setup dialog and the menus use it).DocumentPageable.pageFormatandPrintServiceshrink to one line each. - Calendar deletes
paperFor,renderandprinterNames, and asksPaperSize.forLocaleandPrintJobs. Its test helper that probed for a print service is gone. - Both programs pin the new amade-commons commit, and all tests run.
What stays in the programs is what is really theirs: Calendar's report pages and its lp command with dtcm's options, WritePad's pages and its use of the system print dialog.
What amade-commons gives WritePad¶
| Part | Since | WritePad uses it for |
|---|---|---|
AmadeApplication, CommonsModule, EdtExecutor |
chapter 10 | start-up, wiring, the two threads |
Messages, Mnemonics, LocaleSettings |
chapter 10 | five languages, menu letters, the language setting |
AmadeUI, DesktopTheme |
chapter 10 | the theme's colours for bars, ruler and icons |
AppDirectories, PropertiesFile |
chapter 20 | the preferences: which bars are shown |
PaperSize, PrintJobs |
this chapter | paper sizes in the formats and the page setup; printing |
And what stays in WritePad¶
- The toolbar icons (
ToolIcon,ToolButtons) are drawn in Java2D in the theme's colours, so WritePad needs no icon theme. Fractals draws its toolbar the same way, and they move into amade-commons with it in chapter 30. LibreOffice, the runner forsoffice, moves with Imagescan's command runner in chapter 31.DocumentFileswrites files atomically and keeps their permissions, like Pad. That makes four atomic writes (Pad,PropertiesFile, Calendar, WritePad), and the first exercise of chapter 21 is still open.
Exercises¶
- Keep together. Headings should never be the last line of a page. Add a paragraph attribute "keep with next" and make
PagedSectionViewmove a heading to the next page when the paragraph after it does not start on the same page. Where does the loop stop if every paragraph has the attribute? - Page numbers in the footer. The page view already paints a footer ("1 / 6"). Make it a setting of the document, saved in ODT as a footer with a page-number field. Which part of the code reads it, and which writes it?
- The paper where you live. New documents always start on A4. Use
PaperSize.forLocalefor new documents. What should happen when a document from a Letter country is opened in an A4 country? - A second Ctrl+S that counts. Instead of ignoring a save while one is running, remember it and save again when the first one is done, with the newest text. Write the test first, with the queueing executor.
- Print preview for Calendar.
ReportPagespaints into anyGraphics2D. Show the pages in a window before printing. Could WritePad's page view show Calendar's pages? What would aPreviewPanelin amade-commons need to know about the pages?
Summary¶
- A Swing text component is a document, its elements, the views and an editor kit. Replace the views through the kit's view factory, and typing, caret, selection and undo keep working.
- Lay out in one unit (points) and translate only at the edge; then zoom does not move line breaks, and printing is the same painting at scale 1.
- A layout that moves things needs a case in which it stops moving them.
- Labels of list items are computed from structure, not stored as text.
- ODT and DOCX are ZIP files with XML inside; the JDK's
java.util.zipand StAX read and write them. Recognise files by content first, by name second. - Two writes of the same file must not run at the same time: chain them, or start no second one.
- Paper sizes, page formats, printer attributes, the print dialog and PostScript files move into amade-commons as
PaperSizeandPrintJobs, their second user after Calendar. The JDK's stream print service writes PostScript without a printer.