diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..d63b751
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,33 @@
+# The .iwa and .key fixtures under tests/data/ are binary, but `identify` sees
+# no NUL bytes near the start of some of them and classifies them as text. The
+# whitespace hooks then "fix" them: trailing-whitespace strips any 0x09 that
+# happens to precede a 0x0a, which inside a Snappy stream or an embedded JPEG is
+# just data. That silently corrupts the archive, and the only symptom is
+# zipfile.BadZipFile in an unrelated test run later.
+#
+# Excluding the whole directory rather than a *.key glob is deliberate: the .iwa
+# files have the same problem, and the .yaml fixtures are byte-compared by
+# tests/test_codec.py, so reflowing them would be just as damaging.
+exclude: ^tests/data/
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-toml
+ - id: check-added-large-files
+ - id: check-merge-conflict
+ - id: debug-statements
+
+ # Kept in lockstep with the ruff pinned in .github/workflows/python-package.yml.
+ # If these drift, the hook and CI disagree about what passes, which is how the
+ # lint gate silently stopped meaning anything the last time round.
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.16.2
+ hooks:
+ - id: ruff-check
+ args: [--fix, --exit-non-zero-on-fix]
+ - id: ruff-format
diff --git a/docs/obriensp_docs.md b/docs/obriensp_docs.md
index 92590dd..fe38e8a 100644
--- a/docs/obriensp_docs.md
+++ b/docs/obriensp_docs.md
@@ -57,13 +57,13 @@ The uncompresed IWA contains the Component's objects, serialized consecutively i
Object 0 varint archiveInfoLength
ArchiveInfo archiveInfo
(payload)
-
+
Object 1 varint archiveInfoLength
ArchiveInfo archiveInfo
(payload)
-
+
...
-
+
Object n varint archiveInfoLength
ArchiveInfo archiveInfo
(payload)
@@ -100,7 +100,7 @@ A full dump of the Protobuf messages can be found [here](../iWorkFileInspector/i
### TSPRegistry
The mapping between an object's `MessageInfo.type` and its respective Protobuf message type must by extracted from the iWork applications at runtime. Attaching to Keynote via a debugger and inspecting `[TSPRegistry sharedRegistry]` shows:
- 0x102f24680 KN.ChartInfoGeometryCommandArchive
147 -> 0x102f24650 KN.SlideCollectionCommandSelectionBehaviorArchive
@@ -111,4 +111,4 @@ The mapping between an object's `MessageInfo.type` and its respective Protobuf m
A full list of the type mappings can be found [here](../iWorkFileInspector/iWorkFileInspector/Persistence/MessageTypes/).
## Encryption
-If the document is locked with a password, nearly all files in the bundle are encrypted using [AES128](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard) encryption with [PKCS7](http://en.wikipedia.org/wiki/Padding_\(cryptography\)#PKCS7) padding. For a full description of the encryption format, see [iWork Encrypted Stream](iWork Encrypted Stream.md).
\ No newline at end of file
+If the document is locked with a password, nearly all files in the bundle are encrypted using [AES128](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard) encryption with [PKCS7](http://en.wikipedia.org/wiki/Padding_\(cryptography\)#PKCS7) padding. For a full description of the encryption format, see [iWork Encrypted Stream](iWork Encrypted Stream.md).
diff --git a/protos/versions/14.4/KNArchives.proto b/protos/versions/14.4/KNArchives.proto
index 9f902da..416aa96 100644
--- a/protos/versions/14.4/KNArchives.proto
+++ b/protos/versions/14.4/KNArchives.proto
@@ -832,4 +832,4 @@ message MotionBackgroundFillArchive {
extend .TSD.FillArchive {
optional .KN.MotionBackgroundFillArchive motionBackgroundFill = 200;
-}
\ No newline at end of file
+}
diff --git a/protos/versions/14.4/TSCH3DArchives.proto b/protos/versions/14.4/TSCH3DArchives.proto
index 5ef3773..623b756 100644
--- a/protos/versions/14.4/TSCH3DArchives.proto
+++ b/protos/versions/14.4/TSCH3DArchives.proto
@@ -178,4 +178,4 @@ message Chart3DVectorArchive {
extend .TSD.FillArchive {
optional .TSCH.Chart3DFillArchive fill3d = 100;
-}
\ No newline at end of file
+}
diff --git a/protos/versions/14.4/TSCHArchives.proto b/protos/versions/14.4/TSCHArchives.proto
index 9311c1a..8a04d28 100644
--- a/protos/versions/14.4/TSCHArchives.proto
+++ b/protos/versions/14.4/TSCHArchives.proto
@@ -347,4 +347,4 @@ extend .TSCH.ChartStylePreset {
}
extend .TSCH.PropertyValueStorageContainerArchive {
optional .TSCH.PropertyValueStorageContainerReferenceLinesArchive property_value_storage_container_reference_lines = 10000;
-}
\ No newline at end of file
+}
diff --git a/protos/versions/14.4/TSDArchives.proto b/protos/versions/14.4/TSDArchives.proto
index 606c796..8b1ebde 100644
--- a/protos/versions/14.4/TSDArchives.proto
+++ b/protos/versions/14.4/TSDArchives.proto
@@ -704,4 +704,4 @@ message MovieFingerprintTrack {
extend .TSS.CommandPropertyEntryArchive {
optional .TSD.FillArchive fill = 200;
optional .TSD.StrokeArchive stroke = 201;
-}
\ No newline at end of file
+}
diff --git a/protos/versions/14.4/TSPMessages.proto b/protos/versions/14.4/TSPMessages.proto
index 004a926..84ef18c 100644
--- a/protos/versions/14.4/TSPMessages.proto
+++ b/protos/versions/14.4/TSPMessages.proto
@@ -285,4 +285,4 @@ message FieldOptions {
extend .google.protobuf.FieldOptions {
optional .TSP.FieldOptions field_options = 51234;
-}
\ No newline at end of file
+}
diff --git a/protos/versions/14.4/TSSArchives.proto b/protos/versions/14.4/TSSArchives.proto
index 8d3f8e0..64b0699 100644
--- a/protos/versions/14.4/TSSArchives.proto
+++ b/protos/versions/14.4/TSSArchives.proto
@@ -164,4 +164,4 @@ message CommandPropertyMapArchive {
extend .TSS.CommandPropertyEntryArchive {
optional .TSP.Color color = 8;
-}
\ No newline at end of file
+}
diff --git a/protos/versions/14.4/TSTArchives.proto b/protos/versions/14.4/TSTArchives.proto
index 3d2a2af..0c657e9 100644
--- a/protos/versions/14.4/TSTArchives.proto
+++ b/protos/versions/14.4/TSTArchives.proto
@@ -1466,4 +1466,4 @@ extend .TSS.CommandPropertyEntryArchive {
optional .TST.CellBorderArchive cell_border = 503;
optional .TST.CommentStorageWrapperArchive comment_storage = 504;
optional .TST.CellSpecArchive cell_spec = 505;
-}
\ No newline at end of file
+}
diff --git a/protos/versions/14.4/TSWPArchives.proto b/protos/versions/14.4/TSWPArchives.proto
index c804087..b752e42 100644
--- a/protos/versions/14.4/TSWPArchives.proto
+++ b/protos/versions/14.4/TSWPArchives.proto
@@ -1008,4 +1008,4 @@ message CollaboratorTextCursorSubselectionArchive {
extend .TSS.CommandPropertyEntryArchive {
optional .TSWP.PaddingArchive padding = 100;
-}
\ No newline at end of file
+}