diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -47,6 +47,10 @@ cargo build --release
 
 INDT templates are read the same way as INDD files.
 
+To convert a whole folder, `tools/convert_folder.sh <in> <out>` builds
+the converter and writes one IDML per INDD or INDT file, with its
+warnings in `<name>.warnings.txt`.
+
 As a library:
 
 ```rust
@@ -79,6 +83,7 @@ the same package as a native build (`tools/check_wasm.sh`).
 | Versions | Distinct samples | State |
 |---|---:|---|
 | InDesign 1.x | 8 | Not supported (different container); reported as such. |
+| InDesign 2.0 | – | Convert, checked against the print PDFs of three privately held documents (no IDML pair exists). Image links are read from the old link objects; links of placed text are not written. Of the document preferences only the page setup is read; the page number style of sections and TOC styles are left out with warnings. Spreads are placed by the rule in [`objects.md`](docs/format/objects.md) (spreads without a transform). |
 | InDesign 3.0 and 4.0 | 67 | Convert. Some object layouts differ from later versions ([`big-endian.md`](docs/format/big-endian.md)). Document preferences, object style text frame settings, and the page number style (3.0) are left out with warnings. |
 | InDesign 5.0 and 6.0 | 181 | Convert. Document preferences are left out with a warning. |
 | InDesign 7.0–7.5 | 566 | Convert. Document preferences are left out with a warning. |
diff --git a/docs/format/attributes.md b/docs/format/attributes.md
--- a/docs/format/attributes.md
+++ b/docs/format/attributes.md
@@ -1560,3 +1560,32 @@ string.
 whose family stands for a composite font is written as a reference to
 the composite font, except in character styles. See `fonts.md`,
 composite fonts as applied fonts.
+
+**Alignment in InDesign 2.0.** Files from InDesign 2.0 have no 0x1B7E.
+Paragraph styles and paragraph-style records have two attributes with
+the value type of 0x1B7E (0x1B20, a u16 code; 0x1B7E has this type in
+a 9.2 fixture): 0x1B05, the alignment (0 left, 1 center, 2 right, 3
+justified), and 0x1B04, the alignment of the last line. The root
+paragraph style has both (0 and 0); other styles and records have one or
+both, and inherit the other through `BasedOn`. Over the 82 records of
+three privately held 2.0 documents that set either (`objects.md`, the
+story list of InDesign 2.0), the pairs in effect (0x1B04, 0x1B05)
+are (0, 0) 16, (1, 1) 26, (2, 2) 11, (0, 3) 13, (1, 3) 6 and (2, 3) 10.
+They are mapped as (0, 0) `LeftAlign`, (1, 1) `CenterAlign`, (2, 2)
+`RightAlign`, (0, 3) `LeftJustified`, (1, 3) `CenterJustified` and (2,
+3) `RightJustified`. A seventh pair, (3, 3), occurs once, in a later
+2.0 document: a one-word paragraph whose word the PDF spreads over the
+frame's full width (letters from 86 to 514 pt in a frame from 86.1 to
+513.6), so its last line is justified too; it is mapped as
+`FullyJustified`. Whether 0x1B04 is the alignment of the last line or
+of the whole paragraph cannot be told from these pairs (they agree
+where 0x1B05 is below 3), so no other pair is mapped: the converter
+leaves it out with a warning. Evidence from the print PDFs, over the
+paragraphs of three lines or more whose PDF lines can be told apart:
+65 `LeftJustified` paragraphs are flush on both edges, 4 `CenterAlign`
+centred and 2 `LeftAlign` flush left; 17 more read otherwise only where
+the PDF text joins lines of two columns, or (one paragraph) where the
+left edge steps around a wrapped image. Short paragraphs agree: bylines
+with (2, 3) end at the frame's right edge, a short paragraph with (1,
+3) is centred in its column. Before this, 0x1B04 and 0x1B05 were not read and
+every paragraph of these documents was left aligned.
diff --git a/docs/format/hyperlinks.md b/docs/format/hyperlinks.md
--- a/docs/format/hyperlinks.md
+++ b/docs/format/hyperlinks.md
@@ -399,6 +399,19 @@ text sources (classes 0x13502 and 0x13503), destinations (0x13504,
 a list longer than the chunk. The other hyperlink objects of 5.0 files
 are described below.
 
+**Document lists of InDesign 2.0.** Files from InDesign 2.0 that have
+chunk 0x13501 lay it out as three UID lists and nothing else: text
+sources, destinations, hyperlinks. There is no bookmark list. Evidence:
+six privately held 2.0 documents with their print PDFs have the chunk
+(three earlier ones do not; `objects.md`, the story list of InDesign
+2.0). In all six the three lists end the chunk exactly (16 or 24 bytes),
+the first and third are empty, and the second lists every object of
+class 0x13506 in the file (1 in four files, 3 in two), URL destinations
+whose chunk 0x13509 holds a URL. No file has a bookmark (class 0x1354C).
+Read with the 3.0 layout, the fourth list runs past the chunk, and the
+conversion failed. The converter reads the three lists and returns no
+bookmarks.
+
 ## InDesign 5.0
 
 No 5.0 file has an IDML. The following is from the 17 5.0 files with
diff --git a/docs/format/idml-values.md b/docs/format/idml-values.md
--- a/docs/format/idml-values.md
+++ b/docs/format/idml-values.md
@@ -441,7 +441,18 @@ the last session of the save history (`objects.md`, save history):
 | every other version seen (7.0 to 21.5) | its major and minor version |
 
 The converter writes this value, from the header's version where the
-save history has none or names another major version. It matches 1,164
+save history has none or names another major version.
+
+**Documents from before 7.0.** IDML began with InDesign CS4 (DOM 6.0),
+and the oldest IDML in the corpus is from 7.0; documents from 2.0 to
+5.0 have no IDML pair. Their version is not a DOM version that any
+IDML has, so the converter writes `DOMVersion="7.0"` for them, and the
+`product` of an InDesign 7.0 export, `7.0(330)` (as in IDML files of
+7.0 published with the IDML schemas, metanorma/idml). The values it
+writes for these documents do not change: every version-dependent
+value block and writer branch starts at 8.0 or later. Before this, the
+packages of 2.0 to 5.0 documents carried their own version (`2.0`,
+`3.0`), which no InDesign release exported as IDML. It matches 1,164
 of 1,251 trustworthy pairs (the header's major version with `.0`
 matched 668). The others are files that a later release exported
 without saving them: 68 sessions of 10.1 whose IDML has 10.2, 16 of
diff --git a/docs/format/objects.md b/docs/format/objects.md
--- a/docs/format/objects.md
+++ b/docs/format/objects.md
@@ -62,7 +62,7 @@ are in `big-endian.md`.
 | 0x1401 | UID list: master spreads |
 | 0x301 | UID list: layers (the first is an internal layer that holds pages) |
 | 0x313 | u32: active layer (IDML `ActiveLayer`) |
-| 0x222 | Two UID lists: the stories, then the XML backing story; IDML `StoryList` is both (`xml.md`) |
+| 0x222 | Two UID lists: the stories, then the XML backing story; IDML `StoryList` is both (`xml.md`). InDesign 2.0: one list of 6-byte records (below) |
 | 0x4C01 | UID list: sections |
 | 0xA443 | Document users: u32 count, then per user a flag byte, the name as an in-object string, u32 colour |
 | 0x1D8 | Save history: u32 count, then one record per session (below) |
@@ -78,6 +78,22 @@ keys in 4 pairs, all others 0; the 3 of these pairs whose IDML has a
 document label write the key without the `$ID/` prefix of built-in
 names.
 
+**The story list of InDesign 2.0.** In files from InDesign 2.0, chunk
+0x222 is a u32 count *n* and then *n* records of 6 bytes: a u32 UID and
+a u16 flag. Flag 2 marks the XML backing story (the story chunk 0xBF14
+names); the others have flag 1. There is no second list. Evidence: three
+privately held InDesign 2.0 documents with their print PDFs, which have
+no IDML (`CLEANROOM.md`, points a reviewer should know): the chunk is
+4 + 6*n* bytes in 3 of 3 (19, 30 and 37 records), every record names a
+story object (86 of 86) and the records name every story object of the
+file, flag 2 occurs once per file and names the story of chunk 0xBF14
+in 3 of 3. Read as a UID list, the chunk gives 10 of 30 stories in one
+of them, and the text of the others is missing from the output; read as
+records, 98 to 99 % of the words of each PDF are in the converted
+stories. A later file's two lists can have the same length (four
+stories and one backing story take 28 bytes, as four records do), so
+the converter reads records only in files from 2.0.
+
 **Script byte of the user names.** The second byte of each user name's
 in-object string (the byte after the string tag) is a script code: 0
 in documents in Latin script, 1 in all 9 trustworthy pairs with
@@ -306,6 +322,19 @@ trustworthy pairs:
   equals the IDML list for 9,367 of 9,367 pages (8 with a tab order);
   pages not in the chunk have an empty `TabOrder`.
 
+**Spreads without a transform.** No spread of three privately held
+InDesign 2.0 documents (19 spreads; `objects.md`, the story list of
+InDesign 2.0) has chunk 0x56E, so every spread would be written at the
+origin. IDML exported by InDesign stacks the spreads vertically: each
+spread's `ItemTransform` moves it down from the previous one by half of
+both spreads' page heights, plus 180 pt, plus the top and bottom bleed.
+Evidence: the two fixtures that store spread transforms, 6 of 6 steps
+(a 9.2 file with 1,000 pt pages and no bleed, step 1,180; a 7.5 file
+with 666 pt pages and a 9 pt bleed, step 846). When no spread of a
+document has chunk 0x56E, the converter places the spreads by this
+rule. Not known: the effect of a slug, or of a pasteboard other than
+the default (72 pt above and below).
+
 **Page transitions.** Chunk 0x2050C gives `PageTransitionType` and
 `PageTransitionDirection`. Over the 11,676 spreads of the distinct
 little-endian pairs with the same major version (corpus after 2026-10):
@@ -1415,6 +1444,22 @@ chunk 0x3703 (44 bytes):
 | 40 | u16 master page flag: 1 `ApplyToMasterPageOnly="false"`, 0 `true` |
 | 42 | u16 `TextWrapSide`: 0 `BothSides`, 1 `LeftSide`, 2 `RightSide`, 3 `SideTowardsSpine`, 4 `SideAwayFromSpine`, 5 `LargestArea` |
 
+**Text wrap of InDesign 2.0.** In files from InDesign 2.0 the chunk
+has 40 bytes: the fields up to offset 40 above, without the master
+page flag and the side. The converter reads them and writes
+`ApplyToMasterPageOnly="false"` and `BothSides`, the values of codes 1
+and 0. Evidence: three privately held 2.0 documents with their print
+PDFs (above, the story list of InDesign 2.0): 129 of 129 chunks are
+40 bytes (115 page items, 13 images, 1 group); the modes are 0 (89),
+3 (17) and 6 (23); each of the 40 items with mode 3 or 6 names a wrap
+path object (class 0x3702) whose chunk 0x3709 has 10 bytes and custom
+path flag 0 (40 of 40), so no path is written. In the PDFs, text keeps
+clear of these items: on the pages with mode 6 images and mode 3
+frames, the text beside them is set around them, and a justified
+paragraph's left edge steps right beside an image while its right edge
+stays flush. The converter took the 40-byte chunk for no chunk, and
+wrote every item of these documents with `TextWrapMode="None"`.
+
 **Wrap path.** Every item with wrap mode 1, 2, 3 or 6 (and the codes
 0x10003 and 0x10006) has a wrap path object; mode 0 items have none
 (9,061 objects in 735 files of the corpus of 2026-10). The u32 at 4
@@ -2925,9 +2970,29 @@ no chunk 0x8CBC, and their chunk 0x8C1B (u32) names an object of class
 0x8C01. That object's chunk 0x8C2A names the image back in 759 of 762.
 No file of these versions has an IDML pair, so it is not known whether
 IDML would write the preview as `Contents`. The converter treats these
-images as linked graphics whose link it does not read: no `Contents`,
-and the preview goes to `Conversion::previews`. In files of later
-versions, 0x8C1B of such an image is 0 or absent in every case.
+images as linked graphics: no `Contents`, and the preview goes to
+`Conversion::previews`. It reads their link in files from 2.0 only
+(below); in files from 3.0 to 5.0 the link is not read. In files of
+later versions, 0x8C1B of such an image is 0 or absent in every case.
+
+**Links of InDesign 2.0.** In files from InDesign 2.0 the old link
+object (class 0x8C01) has chunks 0x8C04, 0x8C1B and 0x8C6B. Chunk
+0x8C04 holds the linked file's path as a flag byte and an in-object
+string (tag 1) at offset 24; chunk 0x8C1B names the object the link
+belongs to, an image or a story (placed text). Evidence: three
+privately held 2.0 documents (above, the story list of InDesign 2.0),
+51 of 51 old link objects: the same three chunks in 51, the string at
+24 parses in 51, every path starts with a drive letter (`C:\`). Of the
+51, 28 name an image in chunk 0x8C1B and 23 a story; each of the 28
+images names its link back in its own 0x8C1B (28 of 28), and the
+documents have no other images. The 16 bytes before the path are not
+identified. No IDML exists for these files; the converter writes the
+link of each image as IDML writes a graphic link (class 0x8C42,
+`StoredState="Normal"`), with the path as `LinkResourceURI` by the
+reverse of the `FilePath` rule (`file:`, `\` as `/`, percent-encoded;
+above, movies and sounds), and leaves `LinkResourceFormat`, the import
+stamp and the times out. Links of stories are not written. Not known:
+the form of a path saved on a Mac.
 
 Not known: why these images keep their file in the preview rather than
 in a raw data object named by 0x8C23. None of the 77 images whose 0x8C23
@@ -3545,6 +3610,20 @@ bytes), so the converter locates it as a flag byte followed by a valid
 in-object string.
 All 486 style names and 291 `NextStyle` values in the pairs match.
 
+**Style names in InDesign 2.0.** The name starts at offset 20: u16 kind
+at 16, u16 at 18, flag byte at 20, then an in-object string with tag 1
+(above, in-object strings) that ends the chunk. Evidence: three
+privately held 2.0 documents (above, the story list of InDesign 2.0),
+22 of 22 styles; the kind is 1 in 14 and 0 in 8, and the flag is 1 on
+the two root styles of each document (`[No paragraph style]`,
+`[No character style]`). The search for a flag byte and a string must
+accept tag 1 in these files. In the 6 root styles, bytes 15 to 19 (0, 1,
+0, 0, 0) also read as a flag byte and an empty tag-1 string; the search
+skips empty strings in 2.0 files, as a style name is never empty. Before
+this, every style of these documents was left out ("no name"), the
+paragraphs named styles that the IDML did not define, and the root
+style's font (the default of most text) was lost.
+
 **Imported and unique ID.** Over the 4,694 paragraph and character
 styles of the trustworthy pairs whose IDML style has the same name, the
 u16 after the kind equals `Imported` in 4,694 (180 imported). The GUID
@@ -4216,6 +4295,24 @@ over the trustworthy pairs after 2026-10, 670 more elements, with no
 wrong or extra value (`FirstBaselineOffset` 2,731 of 2,731,
 `AutoSizingType` 16,202 of 16,202).
 
+**First baseline of InDesign 2.0.** Frames of InDesign 2.0 have a
+26-byte chunk 0x2CE. In three privately held 2.0 documents (above, the
+story list of InDesign 2.0) the u16 at 0 is 0 in 95 of 95 frames, but
+the first lines are not set as code 0 (`LeadingOffset`) sets them. In
+their print PDFs, the first baseline of the first frame of a story lies
+below the frame's top inset by the height of the font's `d`, which is
+what `AscentOffset` sets: over the 36 such frames whose font is embedded
+with a `d`, the offset divided by the point size equals the `d`'s top
+divided by the em in 27 (within 0.004; Times New Roman regular, italic
+and bold italic, Garamond bold and bold italic, Palatino) and is within
+0.015 in 4 more (Arial: 0.728, `d` 0.716). Five frames are further off
+and are not explained (very large type, a frame whose first line is not
+the first text). None is one leading (about 1.2 times the size) below
+the top. The converter writes `FirstBaselineOffset="AscentOffset"` for
+every 2.0 frame. With `LeadingOffset`, each frame's first line sat about
+6 pt too low, and short frames such as running heads could not hold
+their text.
+
 The frame itself (class 0x6201) holds the inset spacing in chunk 0x3723
 (44 bytes): f64, u32, then four f64 (left, top, right, bottom). IDML
 writes `InsetSpacing` as a list of top, left, bottom and right, or as
diff --git a/docs/format/preferences.md b/docs/format/preferences.md
--- a/docs/format/preferences.md
+++ b/docs/format/preferences.md
@@ -168,6 +168,19 @@ bleeds 11 of 11 (all four 8.504 in 3, 0 in the others), uniform bleed
 `true` and uniform slug `false` in 11, slugs all 0. As the four bleeds
 are equal in every sample, their order follows the 146-byte chunk.
 
+**The 50-byte chunk.** Files from InDesign 2.0 have a 50-byte chunk
+0x533 whose bytes up to 46 follow the 126-byte layout: f64 page width
+and height at 16 and 24, u8 intent at 32, u8 facing pages at 38 (2
+facing), u16 page binding at 44. It has no bleed or slug fields; the
+converter writes the bleeds as 0. Evidence: three privately held 2.0
+documents with their print PDFs (`objects.md`, the story list of
+InDesign 2.0): the chunk is 50 bytes in 3 of 3; the page size at 16 and
+24 is the size of every page of the PDF and of every page object in 3
+of 3 (612 × 792 pt); byte 38 is 2 in 3 of 3, and every document has
+spreads of two pages, as facing documents do; byte 32 (intent) and the
+binding are 0 in 3 of 3. Before this, the converter left the chunk out
+with a warning and wrote no page size or facing pages.
+
 ## Grids (`GridPreference`)
 
 Chunk 0x55F (36 bytes in all 654 files): f64 at 2 `BaselineDivision`,
@@ -321,6 +334,14 @@ without it have `-1 72` and 72, except one stale pair. Four u32 UIDs of
 interface colours follow; the IDML colours (`LightGray`, `GridBlue`) are
 not in the table of `objects.md`, so they are not written.
 
+The converter does not read the preferences of files from before 7.0
+(their layouts are not known; no IDML pair exists). For them it writes
+the values of IDML without the chunk, `-1 72` and 72, rather than no
+`PasteboardMargins` and `MinimumSpaceAboveAndBelow` at all; the spread
+positions of these files follow from these values (`objects.md`,
+spreads). Big-endian files of 7.0 and later, whose preferences are not
+read either, are left as they were: their pairs may have the chunk.
+
 From DOM 9 the chunk has 34 bytes, and byte 32 is
 `MatchPreviewBackgroundToThemeColor` (4 `true`): 199 of 199 files with
 a 34-byte chunk. Without the chunk, IDML from DOM 9 has `false` (1,156
diff --git a/docs/format/tables.md b/docs/format/tables.md
--- a/docs/format/tables.md
+++ b/docs/format/tables.md
@@ -63,6 +63,31 @@ All 396 tables of the pairs (all versions) have it; 395 have 0 and are
 left to right in IDML, the one table with 1 (DOM 21.5) is right to left.
 IDML writes `TableDirection` on every table of every version.
 
+## Tables of InDesign 2.0
+
+Tables of InDesign 2.0 differ in three places. Evidence: the four tables
+of three privately held 2.0 documents with their print PDFs (`objects.md`,
+the story list of InDesign 2.0).
+
+- **Size (chunk 0xB608)** has 12 bytes: u32 rows, u32 columns, and one
+  more u32, 0 in all four tables; there is no fourth value. Rows and
+  columns match the tables of the PDFs (three of 4 by 4, one of 2 by 4).
+  As the third value is 0 in every table, it is not known whether it
+  counts header or footer rows; the converter reads it as header rows
+  and writes no footer rows.
+- **Row and column groups (chunks 0xB616, 0xB617)** end after each
+  group's attributes, without the 8 bytes that follow them in later
+  files: 8 of 8 chunks parse to their end that way, and none with the 8
+  bytes.
+- **Cell attribute sets** in the cell strand end after their attributes,
+  without the cell style priority and the cell style: the one table with
+  sets (4 sets in each of 2 groups) parses to the end of both groups
+  only that way. The converter writes these cells with no cell style
+  (`[None]`).
+
+Before this, every 2.0 table was left out ("chunk data ends"). Read
+this way, the text of each of the 56 cells is in the PDF (56 of 56).
+
 ## Cells
 
 The part of class 0xB606 owns a strand (chunk 0x261) whose run data
diff --git a/docs/format/unknowns.md b/docs/format/unknowns.md
--- a/docs/format/unknowns.md
+++ b/docs/format/unknowns.md
@@ -89,7 +89,7 @@ Status values:
 | Path names in a legacy code page | keys 0x7D and 0x80 of image chunk 0x1708 | 10 pairs | open: not decoded | 11 `PhotoshopPath` images whose path name is stored as code page 949 or Shift-JIS bytes; the file does not record the code page, and `AppliedPathName` is left out | `objects.md`, clipping path settings |
 | `Index` of an `AlphaChannel` clipping | chunk 0x2C1A | 1 pair | open: no field found | the image stores -1 at 23 and IDML has 0; left out | `objects.md`, clipping path settings |
 | `InvertPath` and `RestrictToFrame` bits | chunk 0x2C1A, byte 25 | 1 pair | open: never varies | bits 0x01 and 0x08 are set together in the one sample; written only when both are equal | `objects.md`, clipping path settings |
-| Images that name an old link object | image chunk 0x8C1B (class 0x8C01), InDesign 2.0 to 5.0 | 79 files | open: no pair | 762 images; the converter writes no `Contents` for them and returns their preview | `objects.md`, images that keep their file in their preview |
+| Images that name an old link object | image chunk 0x8C1B (class 0x8C01), InDesign 2.0 to 5.0 | 79 files | open: no pair | 762 images; the converter writes no `Contents` for them and returns their preview; in 2.0 files it reads the link's path | `objects.md`, images that keep their file in their preview; links of InDesign 2.0 |
 | EPS `Contents` that differ | placed graphics | – | open: not examined | 18 of 515 | `objects.md`, graphics pasted without a link |
 | Anchored settings of some nested items | `AnchoredObjectSetting` | 3 | open: not examined | 388 values | `objects.md`, items inside an anchored item |
 | `FlexObject` | page items | 20–28 pairs | open: not decoded | structures not written | |
diff --git a/src/database.rs b/src/database.rs
--- a/src/database.rs
+++ b/src/database.rs
@@ -391,6 +391,16 @@ pub(crate) mod synthetic {
     /// The database of an image made by [`image`] from the same objects
     /// (UID, class, chunk bytes).
     pub fn database<'a>(bytes: &'a [u8], objects: &[(u32, u32, Vec<u8>)]) -> Database<'a> {
+        database_in(bytes, objects, Encoding::default())
+    }
+
+    /// Like [`database`], for a file of `encoding` (for example one from
+    /// InDesign 2.0).
+    pub fn database_in<'a>(
+        bytes: &'a [u8],
+        objects: &[(u32, u32, Vec<u8>)],
+        encoding: Encoding,
+    ) -> Database<'a> {
         let mut entries: Vec<Entry> = objects
             .iter()
             .enumerate()
@@ -410,7 +420,7 @@ pub(crate) mod synthetic {
             entries,
             classes,
             unclassed: Vec::new(),
-            encoding: Encoding::default(),
+            encoding,
             recorder: None,
         }
     }
diff --git a/src/idml/designmap.rs b/src/idml/designmap.rs
--- a/src/idml/designmap.rs
+++ b/src/idml/designmap.rs
@@ -169,7 +169,13 @@ impl Writer<'_> {
         let mut x = Xml::new();
         x.pi(&format!(
             "aid style=\"50\" type=\"document\" readerVersion=\"6.0\" featureSet=\"257\" product=\"{}\" ",
-            self.dom
+            // Before 7.0, the product of an InDesign 7.0 export, with its
+            // build (`idml-values.md`, DOM version).
+            if self.doc.version.major < 7 {
+                "7.0(330)".to_string()
+            } else {
+                self.dom.clone()
+            }
         ));
         // The stories, then the backing story.
         let stories: Vec<String> = doc
diff --git a/src/idml/mod.rs b/src/idml/mod.rs
--- a/src/idml/mod.rs
+++ b/src/idml/mod.rs
@@ -312,6 +312,12 @@ impl Edition {
 /// the save history, else by the header (`idml-values.md`, DOM version).
 /// Several releases keep the DOM version of an earlier one.
 fn dom_version(doc: &Document) -> String {
+    // IDML began with DOM 6.0, and the corpus has IDML from 7.0 on; a
+    // document from before 7.0 is written as 7.0 (`idml-values.md`, DOM
+    // version).
+    if doc.version.major < 7 {
+        return "7.0".to_string();
+    }
     let header = (doc.version.major, doc.version.minor);
     let (major, minor) = doc
         .last_session_version
diff --git a/src/model/document.rs b/src/model/document.rs
--- a/src/model/document.rs
+++ b/src/model/document.rs
@@ -251,7 +251,7 @@ impl<'a> Reader<'a> {
             .chunk(DOC, chunk::DOC_ACTIVE_LAYER)?
             .map(|d| self.cursor(&d).u32())
             .transpose()?;
-        let (spreads, master_spreads) = self.document_spreads()?;
+        let (mut spreads, master_spreads) = self.document_spreads()?;
         let mut stories = self.document_stories()?;
         let xml_story = self.xml_story()?;
         // Stories of text frames anchored in the XML backing story are not
@@ -288,6 +288,9 @@ impl<'a> Reader<'a> {
         for story in &mut stories {
             story.orientation = self.story_orientation(story.uid);
         }
+        if self.enc().indesign_2() {
+            self.justification_2(&mut objects.styles, &mut stories);
+        }
         let xml = XmlStructure {
             story: xml_story,
             elements: self.xml_elements(&objects.xml_tag_names)?,
@@ -295,6 +298,7 @@ impl<'a> Reader<'a> {
         let tints = self.tints(&mut objects);
         prune_style_groups(&mut objects.style_groups, |m| self.warn(m));
         let preferences = self.document_preferences()?;
+        self.place_spreads(&mut spreads, preferences.as_ref())?;
         let prefs = self.prefs(version)?;
         let toc_styles = self.toc_styles(version);
         let stroke_styles = self.stroke_styles();
@@ -467,13 +471,163 @@ impl<'a> Reader<'a> {
         Ok((spreads, master_spreads))
     }
 
+    /// Give the spreads of a file that stores no spread transform (chunk
+    /// 0x56E; files before InDesign 5.0) the positions InDesign's IDML
+    /// has: each spread's origin is the previous one's plus half of both
+    /// spreads' heights, 180 pt, and the top and bottom bleed
+    /// (`objects.md`, spreads).
+    fn place_spreads(
+        &self,
+        spreads: &mut [Spread],
+        prefs: Option<&DocumentPreferences>,
+    ) -> Result<(), Error> {
+        for s in spreads.iter() {
+            if self.chunk(s.uid, chunk::SPREAD_TRANSFORM)?.is_some() {
+                return Ok(());
+            }
+        }
+        let bleed = prefs.map_or(0.0, |p| p.bleed[0] + p.bleed[1]);
+        // Pages are left, top, right, bottom.
+        let height = |s: &Spread| {
+            s.pages
+                .iter()
+                .map(|p| (p.bounds[3] - p.bounds[1]).abs())
+                .fold(0.0, f64::max)
+        };
+        let mut y = 0.0;
+        for i in 1..spreads.len() {
+            y += (height(&spreads[i - 1]) + height(&spreads[i])) / 2.0 + 180.0 + bleed;
+            spreads[i].transform = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, y]);
+        }
+        Ok(())
+    }
+
+    /// Paragraph alignment of InDesign 2.0: two text attributes, 0x1B05
+    /// (0 left, 1 center, 2 right, 3 justified) and 0x1B04 (the alignment
+    /// of the last line), stand for the one `Justification` (0x1B7E) of
+    /// later files (`attributes.md`, alignment in InDesign 2.0). Wherever a
+    /// style or paragraph sets either, its effective pair, through its
+    /// paragraph style and their `BasedOn` chain, becomes 0x1B7E. Only
+    /// the pairs the samples show are mapped; another pair is left out
+    /// with a warning.
+    fn justification_2(&self, styles: &mut BTreeMap<u32, Style>, stories: &mut [Story]) {
+        const LAST: u32 = 0x1B04;
+        const ALIGN: u32 = 0x1B05;
+        const JUSTIFICATION: u32 = 0x1B7E;
+        let code = |attrs: &Attrs, id: u32| attrs.get(id).and_then(Value::as_u32);
+        // The value of `id` in style `uid` or the styles it is based on.
+        let inherited = |styles: &BTreeMap<u32, Style>, mut uid: Option<u32>, id: u32| {
+            let mut seen = Vec::new();
+            while let Some(u) = uid {
+                if seen.contains(&u) {
+                    break;
+                }
+                seen.push(u);
+                let Some(s) = styles.get(&u) else { break };
+                if let Some(v) = code(&s.attrs, id) {
+                    return Some(v);
+                }
+                uid = s.based_on;
+            }
+            None
+        };
+        let map = |pair: (u32, u32)| -> Option<u16> {
+            Some(match pair {
+                (0, 0) => 0, // LeftAlign
+                (1, 1) => 1, // CenterAlign
+                (2, 2) => 2, // RightAlign
+                (0, 3) => 4, // LeftJustified
+                (1, 3) => 5, // CenterJustified
+                (2, 3) => 6, // RightJustified
+                (3, 3) => 3, // FullyJustified
+                _ => return None,
+            })
+        };
+        let resolve = |attrs: &Attrs, parent: Option<u32>, styles: &BTreeMap<u32, Style>| {
+            if code(attrs, LAST).is_none() && code(attrs, ALIGN).is_none() {
+                return None;
+            }
+            let get = |id| {
+                code(attrs, id)
+                    .or_else(|| inherited(styles, parent, id))
+                    .unwrap_or(0)
+            };
+            Some((get(LAST), get(ALIGN)))
+        };
+        let set = |attrs: &mut Attrs, pair: (u32, u32), what: &str| match map(pair) {
+            Some(j) => {
+                attrs.values.retain(|(id, _)| *id != JUSTIFICATION);
+                attrs.values.push((JUSTIFICATION, Value::Enum(j)));
+            }
+            None => self.warn(format!(
+                "{what}: alignment {} with last line {} is not known; left out",
+                pair.1, pair.0
+            )),
+        };
+        let pairs: Vec<(u32, (u32, u32))> = styles
+            .values()
+            .filter(|s| s.paragraph)
+            .filter_map(|s| resolve(&s.attrs, s.based_on, styles).map(|p| (s.uid, p)))
+            .collect();
+        for (uid, pair) in pairs {
+            if let Some(s) = styles.get_mut(&uid) {
+                set(&mut s.attrs, pair, &format!("style {uid}"));
+            }
+        }
+        for story in stories {
+            for run in &mut story.runs {
+                if let Some(pair) = resolve(&run.paragraph_attrs, run.paragraph_style, styles) {
+                    set(
+                        &mut run.paragraph_attrs,
+                        pair,
+                        &format!("story {}", story.uid),
+                    );
+                }
+            }
+        }
+    }
+
     /// The stories the document lists. A story that cannot be read is
     /// left out with a warning.
     fn document_stories(&self) -> Result<Vec<Story>, Error> {
-        self.each_or_warn("story", chunk::DOC_STORIES, |uid| match self.class(uid) {
-            Some(class::STORY | class::ENDNOTE_STORY) => self.story(uid).map(Some),
-            _ => Ok(None),
-        })
+        let mut out = Vec::new();
+        for uid in self.story_list()? {
+            let story = match self.class(uid) {
+                Some(class::STORY | class::ENDNOTE_STORY) => self.story(uid),
+                _ => continue,
+            };
+            match story {
+                Ok(s) => out.push(s),
+                Err(e) => self.warn(format!("story {uid} left out: {e}")),
+            }
+        }
+        Ok(out)
+    }
+
+    /// The UIDs of the document's story list (chunk 0x222). Files from
+    /// InDesign 2.0 store a u32 count and then 6-byte records, a u32 UID
+    /// and a u16 flag; flag 2 marks the XML backing story, which is
+    /// written separately (`xml_story`). Later files store a UID list
+    /// (`objects.md`, document). A 2.0 chunk of another length is read
+    /// as a UID list.
+    fn story_list(&self) -> Result<Vec<u32>, Error> {
+        let Some(d) = self.chunk(DOC, chunk::DOC_STORIES)? else {
+            return Ok(Vec::new());
+        };
+        let mut c = self.cursor(&d);
+        let n = c.u32()? as usize;
+        let records = n.checked_mul(6).and_then(|b| b.checked_add(4)) == Some(d.len());
+        if !self.enc().indesign_2() || n == 0 || !records {
+            return self.cursor(&d).u32_list();
+        }
+        let mut out = Vec::with_capacity(n);
+        for _ in 0..n {
+            let uid = c.u32()?;
+            if c.u16()? != 2 {
+                out.push(uid);
+            }
+        }
+        Ok(out)
     }
 
     /// Read each object of the document's UID list `list` with `read`.
@@ -951,3 +1105,105 @@ mod percent_tests {
         assert_eq!(percent_decode("100%"), "100%");
     }
 }
+
+#[cfg(test)]
+mod story_list_tests {
+    use super::*;
+    use crate::database::synthetic;
+    use crate::header::{ByteOrder, Version};
+    use crate::object::Encoding;
+
+    fn story_list(chunk: Vec<u8>, encoding: Encoding) -> Vec<u32> {
+        let objects = [(
+            DOC,
+            class::DOCUMENT,
+            synthetic::chunks(&[(chunk::DOC_STORIES, chunk)]),
+        )];
+        let bytes = synthetic::image(&objects);
+        let db = synthetic::database_in(&bytes, &objects, encoding);
+        Reader::new(&db).story_list().unwrap()
+    }
+
+    #[test]
+    fn reads_the_story_records_of_indesign_2() {
+        let v2 = Encoding::new(ByteOrder::Little, Version { major: 2, minor: 0 });
+        let v3 = Encoding::new(ByteOrder::Little, Version { major: 3, minor: 0 });
+        // Four records: the backing story (flag 2) and three stories.
+        let mut records = 4u32.to_le_bytes().to_vec();
+        for (uid, flag) in [(73u32, 2u16), (129, 1), (153, 1), (271, 1)] {
+            records.extend_from_slice(&uid.to_le_bytes());
+            records.extend_from_slice(&flag.to_le_bytes());
+        }
+        assert_eq!(story_list(records.clone(), v2), [129, 153, 271]);
+        // Later files: a UID list, then the backing story's list. Four
+        // stories and one backing story give the same length (28 bytes)
+        // as four 2.0 records, so only the version tells them apart.
+        let mut lists = Vec::new();
+        for v in [4u32, 10, 11, 12, 13, 1, 14] {
+            lists.extend_from_slice(&v.to_le_bytes());
+        }
+        assert_eq!(lists.len(), records.len());
+        assert_eq!(story_list(lists, v3), [10, 11, 12, 13]);
+        // The same 2.0 bytes in a later file are read as a UID list.
+        assert_eq!(story_list(records, v3).len(), 4);
+    }
+}
+
+#[cfg(test)]
+mod justification_tests {
+    use super::*;
+    use crate::database::synthetic;
+
+    fn style(uid: u32, based_on: Option<u32>, values: &[(u32, u16)]) -> Style {
+        let mut attrs = Attrs::default();
+        attrs.values = values.iter().map(|&(id, v)| (id, Value::Enum(v))).collect();
+        Style {
+            uid,
+            name: format!("s{uid}"),
+            builtin: false,
+            paragraph: true,
+            based_on,
+            next: None,
+            attrs,
+            imported: false,
+            unique_id: None,
+            shortcut: None,
+            export: None,
+            preview_color: None,
+        }
+    }
+
+    #[test]
+    fn maps_the_alignment_of_indesign_2_through_based_on() {
+        let objects = [(1, class::DOCUMENT, Vec::new())];
+        let bytes = synthetic::image(&objects);
+        let db = synthetic::database(&bytes, &objects);
+        let reader = Reader::new(&db);
+        let mut styles = BTreeMap::from([
+            (59, style(59, None, &[(0x1B04, 0), (0x1B05, 0)])),
+            (62, style(62, Some(59), &[(0x1B05, 3)])),
+            (63, style(63, Some(62), &[(0x1B04, 1)])),
+            (64, style(64, Some(59), &[(0x1B04, 2)])),
+            (65, style(65, Some(59), &[])),
+            (66, style(66, Some(62), &[(0x1B04, 3)])),
+        ]);
+        reader.justification_2(&mut styles, &mut []);
+        let j = |uid: u32| {
+            styles[&uid]
+                .attrs
+                .values
+                .iter()
+                .find(|(id, _)| *id == 0x1B7E)
+                .map(|(_, v)| v.clone())
+        };
+        assert_eq!(j(59), Some(Value::Enum(0))); // LeftAlign
+        assert_eq!(j(62), Some(Value::Enum(4))); // LeftJustified
+        assert_eq!(j(63), Some(Value::Enum(5))); // CenterJustified, 3 inherited
+        assert_eq!(j(66), Some(Value::Enum(3))); // FullyJustified
+        // Last line right with left alignment: not in the samples.
+        assert_eq!(j(64), None);
+        assert_eq!(reader.warnings.borrow().len(), 1);
+        // A style that sets neither keeps inheriting.
+        assert_eq!(j(65), None);
+    }
+}
diff --git a/src/model/hyperlink.rs b/src/model/hyperlink.rs
--- a/src/model/hyperlink.rs
+++ b/src/model/hyperlink.rs
@@ -484,6 +484,15 @@ impl Bookmark {
 /// bookmarks (`docs/format/hyperlinks.md`).
 pub fn document_bookmarks(enc: Encoding, data: &[u8], major: u32) -> Result<Vec<u32>, Error> {
     let mut c = enc.cursor(data);
+    // InDesign 2.0: three UID lists (text sources, destinations,
+    // hyperlinks) and no bookmark list (`hyperlinks.md`, document lists of
+    // InDesign 2.0).
+    if major <= 2 {
+        for _ in 0..3 {
+            c.u32_list()?;
+        }
+        return Ok(Vec::new());
+    }
     if major <= 5 {
         for _ in 0..3 {
             c.u32_list()?;
@@ -685,3 +694,21 @@ mod tests {
         assert_eq!(got, [(122, 22, 0xA), (260, 38, 0xB), (380, 15, 0xC)]);
     }
 }
+
+#[cfg(test)]
+mod document_list_tests {
+    use super::*;
+
+    #[test]
+    fn reads_the_document_lists_of_indesign_2() {
+        let enc = Encoding::default();
+        // No text sources, one URL destination (UID 1174), no hyperlinks.
+        let mut d = Vec::new();
+        for v in [0u32, 1, 1174, 0] {
+            d.extend_from_slice(&v.to_le_bytes());
+        }
+        assert_eq!(document_bookmarks(enc, &d, 2).unwrap(), Vec::<u32>::new());
+        // Read as a 3.0 to 5.0 chunk, the fourth list is missing.
+        assert!(document_bookmarks(enc, &d, 4).is_err());
+    }
+}
diff --git a/src/model/ids.rs b/src/model/ids.rs
--- a/src/model/ids.rs
+++ b/src/model/ids.rs
@@ -38,6 +38,9 @@ pub mod class {
     pub const RAW_DATA: u32 = 0x129;
     /// A graphic's screen preview; chunk 0x119 names its raw data object.
     pub const PREVIEW: u32 = 0x1708;
+    /// The old link object that images of InDesign 2.0 to 5.0 name in
+    /// chunk 0x8C1B (`objects.md`, links of InDesign 2.0).
+    pub const OLD_LINK: u32 = 0x8C01;
     pub const FONT_FAMILY: u32 = 0x3E03;
     pub const LANGUAGE: u32 = 0x2D07;
     pub const TOC_STYLE: u32 = 0x11605;
@@ -342,6 +345,9 @@ pub mod chunk {
     /// u32 object of class 0x8C01 that images of InDesign 2.0 to 5.0 name
     /// (0 = none).
     pub const OLD_GRAPHIC_LINK: u32 = 0x8C1B;
+    /// Old link object: the linked file's path, a flagged in-object string
+    /// at offset 24 in files from InDesign 2.0.
+    pub const OLD_LINK_PATH: u32 = 0x8C04;
     pub const LINK_INFO: u32 = 0x8C9B;
     pub const LINK_RESOURCE_URI: u32 = 0x8C92;
     /// u32 PDF identifier of a link.
diff --git a/src/model/item.rs b/src/model/item.rs
--- a/src/model/item.rs
+++ b/src/model/item.rs
@@ -373,6 +373,26 @@ pub struct Link {
     pub format: Option<String>,
 }
 
+/// The `LinkResourceURI` of a Windows path with a drive letter
+/// (`C:\dir\a b.tif` gives `file:C:/dir/a%20b.tif`): the reverse of the
+/// rule from link URI to native path (`objects.md`, `FilePath`). `None`
+/// for any other path.
+pub(super) fn windows_file_uri(path: &str) -> Option<String> {
+    let b = path.as_bytes();
+    if b.len() < 3 || !b[0].is_ascii_alphabetic() || b[1] != b':' || b[2] != b'\\' {
+        return None;
+    }
+    let mut uri = String::from("file:");
+    for &c in path.replace('\\', "/").as_bytes() {
+        if c.is_ascii_alphanumeric() || b"-._~/:".contains(&c) {
+            uri.push(c as char);
+        } else {
+            uri.push_str(&format!("%{c:02X}"));
+        }
+    }
+    Some(uri)
+}
+
 /// The bytes of a file stored in the document, if there is one.
 pub(super) type EmbeddedFile = Option<Vec<u8>>;
 
@@ -770,7 +790,14 @@ impl<'a> Reader<'a> {
             column_count: self.cursor(&cols).u32()?,
             column_gutter: self.cursor(&cols[4..]).f64()?,
             column_fixed_width: self.cursor(&cols[14..]).f64()?,
-            first_baseline_offset: self.cursor(&just).u16()?,
+            // InDesign 2.0 frames set the first baseline as `AscentOffset`
+            // does, whatever the u16 at 0 (`objects.md`, first baseline of
+            // InDesign 2.0).
+            first_baseline_offset: if self.enc().indesign_2() {
+                1
+            } else {
+                self.cursor(&just).u16()?
+            },
             vertical_justification: self.cursor(&just[2..]).u16()?,
             vertical_balance_columns: self.cursor(&just[20..]).u16()? != 0,
             auto_sizing_type: auto_sizing.then(|| self.enc().u16_at(&just, 22)).flatten(),
@@ -1001,15 +1028,19 @@ impl<'a> Reader<'a> {
         let Some(d) = self.chunk(uid, id)? else {
             return Ok(None);
         };
-        if d.len() < 44 {
+        // InDesign 2.0 stores the first 40 bytes only, without the master
+        // page flag and the side (`objects.md`, text wrap of InDesign 2.0):
+        // they get the values of `ApplyToMasterPageOnly="false"` and
+        // `BothSides`.
+        let short = d.len() == 40 && self.enc().indesign_2();
+        if d.len() < 44 && !short {
             return Ok(None);
         }
         let mut c = self.cursor(&d);
         let mode = c.u32()?;
         let path_uid = c.u32()?;
         let offsets = [c.f64()?, c.f64()?, c.f64()?, c.f64()?];
-        let master_flag = c.u16()?;
-        let side = c.u16()?;
+        let (master_flag, side) = if short { (1, 0) } else { (c.u16()?, c.u16()?) };
         if !matches!(
             mode,
             wrap_mode::NONE | wrap_mode::JUMP_OBJECT | wrap_mode::BOUNDING_BOX | wrap_mode::CONTOUR
@@ -1443,6 +1474,13 @@ impl<'a> Reader<'a> {
             Some(d) if d.len() >= 12 => self.link(self.cursor(&d[8..]).u32()?)?,
             _ => None,
         };
+        // Images of InDesign 2.0 name their link in an old link object.
+        let link = match link {
+            None if kind == GraphicKind::Image && self.enc().indesign_2() => {
+                self.old_link_2(uid)?.map(|l| (l, None))
+            }
+            link => link,
+        };
         let (link, contents) = match link {
             Some((link, data)) => (Some(link), data),
             None => {
@@ -1670,6 +1708,36 @@ impl<'a> Reader<'a> {
         })
     }
 
+    /// The link of an image of InDesign 2.0: chunk 0x8C1B names an object
+    /// of class 0x8C01 whose chunk 0x8C04 holds the file's path at offset
+    /// 24 (`objects.md`, links of InDesign 2.0). Only drive-letter paths
+    /// are known; for other paths, or a chunk that does not parse, there
+    /// is no link and a warning.
+    fn old_link_2(&self, uid: u32) -> Result<Option<Link>, Error> {
+        let object = match self.chunk(uid, chunk::OLD_GRAPHIC_LINK)? {
+            Some(d) if d.len() >= 4 => self.cursor(&d).u32()?,
+            _ => return Ok(None),
+        };
+        if object == 0 || self.class(object) != Some(class::OLD_LINK) {
+            return Ok(None);
+        }
+        let path = match self.chunk(object, chunk::OLD_LINK_PATH)? {
+            Some(d) if d.len() > 24 => self.cursor(&d[24..]).name().ok(),
+            _ => None,
+        };
+        match path.and_then(|p| windows_file_uri(&p.name)) {
+            Some(uri) => Ok(Some(Link {
+                uid: object,
+                uri,
+                ..Link::default()
+            })),
+            None => {
+                self.warn(format!("image {uid}: path of link {object} not read"));
+                Ok(None)
+            }
+        }
+    }
+
     /// The raw data object of graphic `uid`'s preview, if it names one.
     /// The data object itself is not read.
     fn preview(&self, uid: u32) -> Result<Option<u32>, Error> {
@@ -2236,3 +2304,116 @@ mod preview_tests {
         assert_eq!(image.preview, Some(12));
     }
 }
+
+#[cfg(test)]
+mod old_link_tests {
+    use super::*;
+    use crate::database::synthetic;
+    use crate::header::{ByteOrder, Version};
+
+    #[test]
+    fn sets_the_first_baseline_of_indesign_2_frames() {
+        // Chunk 0x2D1 (one column) and the 26-byte chunk 0x2CE of 2.0.
+        let mut cols = 1u32.to_le_bytes().to_vec();
+        cols.resize(22, 0);
+        let mut just = vec![0u8; 26];
+        just[6] = 1;
+        let objects = [
+            (10, class::SPLINE_ITEM, Vec::new()),
+            (
+                11,
+                0x263,
+                synthetic::chunks(&[
+                    (chunk::FRAME_COLUMNS, cols),
+                    (chunk::FRAME_JUSTIFICATION, just),
+                ]),
+            ),
+        ];
+        let bytes = synthetic::image(&objects);
+        let v2 = Encoding::new(ByteOrder::Little, Version { major: 2, minor: 0 });
+        let first = |db: &crate::database::Database| {
+            Reader::new(db)
+                .text_frame_preferences(10, 11)
+                .unwrap()
+                .unwrap()
+                .first_baseline_offset
+        };
+        // `AscentOffset` in a 2.0 file, the stored code (0) otherwise.
+        assert_eq!(first(&synthetic::database_in(&bytes, &objects, v2)), 1);
+        assert_eq!(first(&synthetic::database(&bytes, &objects)), 0);
+    }
+
+    #[test]
+    fn reads_the_text_wrap_of_indesign_2() {
+        let mut d = 6u32.to_le_bytes().to_vec();
+        d.extend_from_slice(&11u32.to_le_bytes());
+        for v in [1.0f64, 1.0, 4.0, 1.0] {
+            d.extend_from_slice(&v.to_le_bytes());
+        }
+        assert_eq!(d.len(), 40);
+        let objects = [(
+            10,
+            class::SPLINE_ITEM,
+            synthetic::chunks(&[(chunk::TEXT_WRAP, d)]),
+        )];
+        let bytes = synthetic::image(&objects);
+        let v2 = Encoding::new(ByteOrder::Little, Version { major: 2, minor: 0 });
+        let db = synthetic::database_in(&bytes, &objects, v2);
+        let w = Reader::new(&db)
+            .wrap_chunk(10, chunk::TEXT_WRAP)
+            .unwrap()
+            .unwrap();
+        assert_eq!((w.mode, w.path_uid), (6, 11));
+        assert_eq!(w.offsets, [1.0, 1.0, 4.0, 1.0]);
+        // `ApplyToMasterPageOnly="false"`, `BothSides`.
+        assert_eq!((w.master_flag, w.side), (1, 0));
+        // A 40-byte chunk in a later file is still not read.
+        let db = synthetic::database(&bytes, &objects);
+        assert_eq!(
+            Reader::new(&db).wrap_chunk(10, chunk::TEXT_WRAP).unwrap(),
+            None
+        );
+    }
+
+    #[test]
+    fn makes_file_uris_of_windows_paths() {
+        assert_eq!(
+            windows_file_uri(r"C:\Dir\Sub dir\a b.tif").as_deref(),
+            Some("file:C:/Dir/Sub%20dir/a%20b.tif")
+        );
+        assert_eq!(windows_file_uri("Disk:Folder:a.tif"), None);
+        assert_eq!(windows_file_uri(""), None);
+    }
+
+    #[test]
+    fn reads_the_image_links_of_indesign_2() {
+        let v2 = Encoding::new(ByteOrder::Little, Version { major: 2, minor: 0 });
+        let mut path = vec![0u8; 24];
+        path.extend(synthetic::flagged_string(v2, 0, r"C:\Images\photo 1.tif"));
+        let objects = [
+            (
+                10,
+                class::IMAGE,
+                synthetic::chunks(&[(chunk::OLD_GRAPHIC_LINK, 11u32.to_le_bytes().to_vec())]),
+            ),
+            (
+                11,
+                class::OLD_LINK,
+                synthetic::chunks(&[
+                    (chunk::OLD_LINK_PATH, path),
+                    (chunk::OLD_GRAPHIC_LINK, 10u32.to_le_bytes().to_vec()),
+                ]),
+            ),
+        ];
+        let bytes = synthetic::image(&objects);
+        let db = synthetic::database_in(&bytes, &objects, v2);
+        let image = Reader::new(&db).graphic(10).unwrap().unwrap();
+        let link = image.link.unwrap();
+        assert_eq!((link.uid, link.class), (11, 0));
+        assert_eq!(link.uri, "file:C:/Images/photo%201.tif");
+        assert_eq!(image.contents, None);
+        // The same objects in a later file: no link is read.
+        let db = synthetic::database(&bytes, &objects);
+        assert_eq!(Reader::new(&db).graphic(10).unwrap().unwrap().link, None);
+    }
+}
diff --git a/src/model/prefs.rs b/src/model/prefs.rs
--- a/src/model/prefs.rs
+++ b/src/model/prefs.rs
@@ -1231,7 +1231,24 @@ impl Reader<'_> {
         // The layouts below are those of InDesign CS5 (7.0) and later in
         // little-endian files: the versions the corpus pairs show.
         if major < 7 || self.enc().big_endian() {
-            return Ok(Prefs::default());
+            // The preferences of these files are not read. Files from
+            // before 7.0 get the pasteboard of every IDML without chunk
+            // 0x5D2 (`preferences.md`, pasteboard).
+            let mut p = Prefs::default();
+            if self.enc().big_endian() && major >= 7 {
+                return Ok(p);
+            }
+            for (name, value) in [
+                ("PasteboardMargins", "-1 72"),
+                ("MinimumSpaceAboveAndBelow", "72"),
+            ] {
+                p.values.push(PrefValue {
+                    element: "PasteboardPreference",
+                    name,
+                    value: value.into(),
+                });
+            }
+            return Ok(p);
         }
         let Some(&(uid, _)) = self
             .db
@@ -2579,6 +2596,26 @@ impl Reader<'_> {
 mod tests {
     use super::*;
 
+    #[test]
+    fn files_before_7_get_the_default_pasteboard() {
+        use crate::database::synthetic;
+        let objects = [(1, class::DOCUMENT, Vec::new())];
+        let bytes = synthetic::image(&objects);
+        let db = synthetic::database(&bytes, &objects);
+        let prefs = Reader::new(&db)
+            .prefs(crate::header::Version { major: 4, minor: 0 })
+            .unwrap();
+        let get = |name: &str| {
+            prefs
+                .values
+                .iter()
+                .find(|v| v.element == "PasteboardPreference" && v.name == name)
+                .map(|v| v.value.as_str())
+        };
+        assert_eq!(get("PasteboardMargins"), Some("-1 72"));
+        assert_eq!(get("MinimumSpaceAboveAndBelow"), Some("72"));
+    }
+
     #[test]
     fn reads_footnote_options() {
         let enc = Encoding::default();
diff --git a/src/model/settings.rs b/src/model/settings.rs
--- a/src/model/settings.rs
+++ b/src/model/settings.rs
@@ -193,6 +193,20 @@ impl<'a> Reader<'a> {
         let Some(d) = self.chunk(uid, chunk::DOCUMENT_PREFERENCES)? else {
             return Ok(None);
         };
+        // InDesign 2.0: the first 46 bytes of the 126-byte layout, without
+        // bleed and slug (`preferences.md`, the 50-byte chunk).
+        if d.len() == 50 && self.enc().indesign_2() {
+            let mut c = self.cursor(&d[16..]);
+            let (page_width, page_height) = (c.f64()?, c.f64()?);
+            return Ok(Some(DocumentPreferences {
+                page_width,
+                page_height,
+                facing_pages: d[38] == 2,
+                bleed: [0.0; 4],
+                intent: u32::from(d[32]),
+                page_binding: self.cursor(&d[44..]).u16()?,
+            }));
+        }
         let Some(l) = SetupLayout::of(d.len()) else {
             self.warn(format!(
                 "document preferences of {} bytes are not known; left out",
@@ -916,3 +930,45 @@ mod shading_tests {
         assert!(axial_shading_be(Encoding::default(), &space).is_err());
     }
 }
+
+#[cfg(test)]
+mod setup_tests {
+    use super::*;
+    use crate::database::synthetic;
+    use crate::header::{ByteOrder, Version};
+
+    #[test]
+    fn reads_the_document_setup_of_indesign_2() {
+        let mut d = vec![0u8; 50];
+        d[16..24].copy_from_slice(&612f64.to_le_bytes());
+        d[24..32].copy_from_slice(&792f64.to_le_bytes());
+        d[34] = 1;
+        d[38] = 2;
+        d[42] = 1;
+        let objects = [(
+            3,
+            class::PREFERENCES,
+            synthetic::chunks(&[(chunk::DOCUMENT_PREFERENCES, d)]),
+        )];
+        let bytes = synthetic::image(&objects);
+        let v2 = Encoding::new(ByteOrder::Little, Version { major: 2, minor: 0 });
+        let db = synthetic::database_in(&bytes, &objects, v2);
+        let reader = Reader::new(&db);
+        assert_eq!(
+            reader.document_preferences().unwrap(),
+            Some(DocumentPreferences {
+                page_width: 612.0,
+                page_height: 792.0,
+                facing_pages: true,
+                bleed: [0.0; 4],
+                intent: 0,
+                page_binding: 0,
+            })
+        );
+        assert!(reader.warnings.borrow().is_empty());
+        // The same chunk in a later file is still not known.
+        let db = synthetic::database(&bytes, &objects);
+        let reader = Reader::new(&db);
+        assert_eq!(reader.document_preferences().unwrap(), None);
+    }
+}
diff --git a/src/model/strings.rs b/src/model/strings.rs
--- a/src/model/strings.rs
+++ b/src/model/strings.rs
@@ -18,7 +18,8 @@ pub(super) fn find_flagged_string(
     let big = enc.big_endian();
     (from..data.len().saturating_sub(6)).find_map(|i| {
         let header = match big {
-            false => data[i] <= 2 && data[i + 1] == 2,
+            // InDesign 2.0 strings start with 1 (`Encoding::string_tag`).
+            false => data[i] <= 2 && data[i + 1] == enc.string_tag(),
             true => data[i] == 2 && data[i + 1] <= 2,
         };
         if !header {
@@ -26,6 +27,11 @@ pub(super) fn find_flagged_string(
         }
         let mut c = enc.cursor(&data[i..]);
         let n = c.name().ok()?;
+        // With tag 1 the pattern also matches zero bytes before the real
+        // string and reads an empty one (objects.md, styles).
+        if enc.indesign_2() && n.name.is_empty() {
+            return None;
+        }
         accept(&n.name).then_some((i, n.builtin, n.name))
     })
 }
@@ -67,6 +73,28 @@ pub(super) fn find_string(enc: Encoding, data: &[u8], from: usize) -> Result<Str
 mod tests {
     use super::*;
 
+    #[test]
+    fn finds_flagged_names_of_indesign_2() {
+        use crate::database::synthetic;
+        use crate::header::{ByteOrder, Version};
+        let v2 = Encoding::new(ByteOrder::Little, Version { major: 2, minor: 0 });
+        // Chunk 0x230 of a root paragraph style: next and based-on 0, u32
+        // 0, u32 0, u16 1 (paragraph), u16 0, then the built-in name. Bytes
+        // 15 to 19 (0, 1, 0, 0, 0) look like an empty tag-1 string.
+        let mut d = vec![0u8; 16];
+        d.extend_from_slice(&[1, 0, 0, 0]);
+        d.extend(synthetic::flagged_string(v2, 1, "[No paragraph style]"));
+        assert_eq!(
+            find_flagged_string(v2, &d, 12, |_| true),
+            Some((20, true, "[No paragraph style]".to_string()))
+        );
+        // Files from 3.0 on: tag 2 only, as before.
+        assert_eq!(
+            find_flagged_string(Encoding::default(), &d, 12, |_| true),
+            None
+        );
+    }
+
     #[test]
     fn finds_strings_with_any_second_byte() {
         let enc = Encoding::default();
diff --git a/src/model/table.rs b/src/model/table.rs
--- a/src/model/table.rs
+++ b/src/model/table.rs
@@ -231,7 +231,11 @@ fn groups(
         c.u16()?;
         let na = c.u16()? as usize;
         let attrs = Attrs::parse_text(&mut c, na, List::Table, recorder)?;
-        c.skip(8)?;
+        // Groups of InDesign 2.0 end after their attributes (`tables.md`,
+        // tables of InDesign 2.0).
+        if !enc.indesign_2() {
+            c.skip(8)?;
+        }
         out.push((count, attrs));
     }
     Ok(out)
@@ -312,7 +316,13 @@ impl Reader<'_> {
         let nrows = c.u32()? as usize;
         let ncols = c.u32()? as usize;
         let header_rows = c.u32()?;
-        let footer_rows = c.u32()?;
+        // InDesign 2.0 stores 12 bytes, without the footer row count
+        // (`tables.md`, tables of InDesign 2.0).
+        let footer_rows = if size.len() == 12 && self.enc().indesign_2() {
+            0
+        } else {
+            c.u32()?
+        };
 
         let column_groups = match self.chunk(uid, chunk::TABLE_COLUMNS)? {
             Some(d) => groups(self.enc(), &d, self.db.recorder())?,
@@ -457,8 +467,13 @@ impl Reader<'_> {
                     } else {
                         Attrs::default()
                     };
-                    let style_priority = g.u32()?;
-                    let style = g.u32()?;
+                    // InDesign 2.0 sets have no cell style (`tables.md`,
+                    // tables of InDesign 2.0).
+                    let (style_priority, style) = if self.enc().indesign_2() {
+                        (0, 0)
+                    } else {
+                        (g.u32()?, g.u32()?)
+                    };
                     all_formats.push(CellFormat {
                         attrs,
                         style_priority,
@@ -1005,3 +1020,33 @@ mod tests {
         );
     }
 }
+
+#[cfg(test)]
+mod indesign_2_tests {
+    use super::*;
+    use crate::header::{ByteOrder, Version};
+
+    #[test]
+    fn reads_the_row_groups_of_indesign_2() {
+        // One group of 4 rows: u32 count, u16 1, u16 one attribute (row
+        // height 0xB60C, a payload with one f64 value), and no trailer.
+        let mut d = 1u32.to_le_bytes().to_vec();
+        d.extend_from_slice(&4u32.to_le_bytes());
+        d.extend_from_slice(&1u16.to_le_bytes());
+        d.extend_from_slice(&1u16.to_le_bytes());
+        d.extend_from_slice(&0xB60Cu32.to_le_bytes());
+        d.extend_from_slice(&16u16.to_le_bytes());
+        d.extend_from_slice(&1u16.to_le_bytes());
+        d.extend_from_slice(&0xB61Du32.to_le_bytes());
+        d.extend_from_slice(&8u16.to_le_bytes());
+        d.extend_from_slice(&18.75f64.to_le_bytes());
+        assert_eq!(d.len(), 34);
+        let v2 = Encoding::new(ByteOrder::Little, Version { major: 2, minor: 0 });
+        let g = groups(v2, &d, None).unwrap();
+        assert_eq!(g.len(), 1);
+        assert_eq!(g[0].0, 4);
+        assert_eq!(f64_attr(&g[0].1, 0xB60C), Some(18.75));
+        // A later file expects 8 more bytes after the attributes.
+        assert!(groups(Encoding::default(), &d, None).is_err());
+    }
+}
diff --git a/src/object.rs b/src/object.rs
--- a/src/object.rs
+++ b/src/object.rs
@@ -69,6 +69,12 @@ impl Encoding {
         self.string_tag
     }
 
+    /// Whether the file is from InDesign 2.0, whose layouts of some
+    /// chunks differ from those of later versions (`docs/format/`).
+    pub fn indesign_2(self) -> bool {
+        self.string_tag == 1
+    }
+
     /// A cursor over object data in this encoding.
     pub fn cursor(self, data: &[u8]) -> Cursor<'_> {
         Cursor {
diff --git a/tests/fixtures.rs b/tests/fixtures.rs
--- a/tests/fixtures.rs
+++ b/tests/fixtures.rs
@@ -131,7 +131,7 @@ fn big_endian_fixtures_convert() {
         (
             "opf-neddy-flyer/Neddy_Flyer_HeatherRyan.indd",
             &[
-                "DOMVersion=\"3.0\"",
+                "DOMVersion=\"7.0\"",
                 "GeometricBounds=\"0 0 792 612\"",
                 "<Content>Ned the Narcoleptic</Content>",
                 "<Content>The Dream Job!</Content>",
@@ -144,7 +144,7 @@ fn big_endian_fixtures_convert() {
         (
             "xmp-toolkit-bluesquare/BlueSquare.indd",
             &[
-                "DOMVersion=\"4.0\"",
+                "DOMVersion=\"7.0\"",
                 "GeometricBounds=\"0 0 792 612\"",
                 "<Rectangle Self=\"uad\"",
                 "PostScriptName=\"Times-Roman\"",
diff --git a/tools/convert_folder.sh b/tools/convert_folder.sh
new file mode 100755
--- /dev/null
+++ b/tools/convert_folder.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+# Convert every INDD and INDT file in a folder to IDML.
+#
+# Usage: tools/convert_folder.sh <input-folder> <output-folder>
+#
+# Builds the converter (needs a recent stable Rust), then writes
+# <output-folder>/<name>.idml for each <input-folder>/<name>.indd or .indt.
+# Warnings of a conversion go to <output-folder>/<name>.warnings.txt (the
+# file is removed when there are none). A file that does not convert is
+# reported as FAILED, and the script goes on with the next one. It exits
+# with 1 if any file failed.
+set -eu
+if [ $# -ne 2 ]; then
+    echo "usage: $0 <input-folder> <output-folder>" >&2
+    exit 2
+fi
+in=$1
+out=$2
+root=$(cd "$(dirname "$0")/.." && pwd)
+(cd "$root" && cargo build --release --quiet)
+bin="$root/target/release/indd"
+mkdir -p "$out"
+converted=0
+failed=0
+for f in "$in"/*.indd "$in"/*.INDD "$in"/*.indt "$in"/*.INDT; do
+    [ -f "$f" ] || continue
+    base=$(basename "$f")
+    name=${base%.*}
+    warnings="$out/$name.warnings.txt"
+    if "$bin" convert "$f" "$out/$name.idml" 2>"$warnings"; then
+        converted=$((converted + 1))
+        if [ -s "$warnings" ]; then
+            echo "$base: converted, $(wc -l <"$warnings" | tr -d ' ') warning(s)"
+        else
+            rm -f "$warnings"
+            echo "$base: converted"
+        fi
+    else
+        failed=$((failed + 1))
+        rm -f "$out/$name.idml"
+        echo "FAILED: $base (see $warnings)"
+    fi
+done
+echo "$converted converted, $failed failed"
+[ "$failed" -eq 0 ]
