From dd1f6afa96cde24bfc6f38cb3effa683f83367fa Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Sat, 26 Sep 2026 16:43:14 +0200 Subject: [PATCH] perf(odt): stream unprocessed package entries instead of inflating them, bound XML part sizes, truncate seekable outputs (#138) - OdtPackage inflates only mimetype, content.xml, styles.xml, meta.xml and the manifest (each at most 256 MB uncompressed, at most 65,535 entries); all other entries are streamed from the source archive at save time. A 100 KB template with a 96 MB entry allocated ~353 MB before and ~1.3 MB now. - Save cuts off a seekable output after the written package (File.OpenWrite on a longer existing file no longer leaves trailing bytes). - DocumentTemplateProcessor truncates the output after copying the template, so an existing longer file opened with FileMode.OpenOrCreate no longer fails as corrupted. --- .../Integration/StreamValidationTests.cs | 27 ++ .../Odt/OdtPackageTests.cs | 116 +++++++++ .../Core/DocumentTemplateProcessor.cs | 8 + .../Core/OdtTemplateProcessor.cs | 3 +- TriasDev.Templify/OpenDocument/OdtPackage.cs | 244 ++++++++++++++---- docs/for-developers/opendocument.md | 16 ++ .../specs/2026-09-26-odt-support-design.md | 2 +- 7 files changed, 363 insertions(+), 53 deletions(-) diff --git a/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs b/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs index 603efbb..cf2b081 100644 --- a/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs +++ b/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs @@ -115,6 +115,33 @@ public void ProcessTemplate_NonSeekableTemplate_IsSupported() Assert.Equal("Hello Alice!", verifier.GetParagraphText(0)); } + [Fact] + public void ProcessTemplate_ExistingLongerOutputFile_IsTruncated() + { + // An existing file opened without truncation (FileMode.OpenOrCreate) used to keep its trailing bytes after + // the template copy, so the package could not be opened and processing failed. + string path = Path.Combine(Path.GetTempPath(), "templify-longer-" + Guid.NewGuid().ToString("N") + ".docx"); + try + { + File.WriteAllBytes(path, new byte[200 * 1024]); + ProcessingResult result; + using (FileStream output = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite)) + { + result = new DocumentTemplateProcessor().ProcessTemplate(CreateTemplate(), output, _data); + } + + Assert.True(result.IsSuccess, result.ErrorMessage); + byte[] written = File.ReadAllBytes(path); + Assert.True(written.Length < 200 * 1024); + using DocumentVerifier verifier = new DocumentVerifier(new MemoryStream(written)); + Assert.Equal("Hello Alice!", verifier.GetParagraphText(0)); + } + finally + { + File.Delete(path); + } + } + private static void AssertOutputStreamFailure(ProcessingResult result) { Assert.False(result.IsSuccess); diff --git a/TriasDev.Templify.Tests/Odt/OdtPackageTests.cs b/TriasDev.Templify.Tests/Odt/OdtPackageTests.cs index 33cbc4e..7558f0f 100644 --- a/TriasDev.Templify.Tests/Odt/OdtPackageTests.cs +++ b/TriasDev.Templify.Tests/Odt/OdtPackageTests.cs @@ -339,6 +339,122 @@ public void ProcessTemplateFile_WritesOutputFile_OnlyOnSuccess() } } + [Fact] + public void Process_LargeUnprocessedEntry_IsStreamedWithoutInflatingIntoMemory() + { + // A 64 MB entry that processing never reads (a picture) compresses to about 64 KB. It used to be inflated + // into memory and buffered twice (more than 3x its inflated size was allocated). + byte[] template = CreateTemplateWithLargeEntry(out int entrySize); + + long before = GC.GetAllocatedBytesForCurrentThread(); + ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(template, _data, out byte[] output); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(result.IsSuccess, result.ErrorMessage); + Assert.True(allocated < entrySize / 4, $"Allocated {allocated:N0} bytes for a {entrySize:N0}-byte entry."); + AssertLargeEntryCopied(output, entrySize); + } + + [Fact] + public void Process_NonSeekableTemplateWithLargeEntry_BuffersOnlyCompressedData() + { + byte[] template = CreateTemplateWithLargeEntry(out int entrySize); + using NonSeekableStream input = new NonSeekableStream(new MemoryStream(template)); + using MemoryStream output = new MemoryStream(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(input, output, _data); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(result.IsSuccess, result.ErrorMessage); + Assert.True(allocated < entrySize / 4, $"Allocated {allocated:N0} bytes for a {entrySize:N0}-byte entry."); + AssertLargeEntryCopied(output.ToArray(), entrySize); + } + + [Fact] + public void Open_XmlPartLargerThanLimit_ThrowsInvalidPackage() + { + byte[] template = new OdtDocumentBuilder().AddParagraph(new string('x', 4096)).ToBytes(); + + TriasDev.Templify.OpenDocument.InvalidOdtPackageException exception = + Assert.Throws( + () => TriasDev.Templify.OpenDocument.OdtPackage.Open(new MemoryStream(template), maxXmlPartBytes: 2048)); + + Assert.Equal( + "Invalid document: content.xml exceeds the maximum supported size (2048 bytes uncompressed).", + exception.Message); + } + + [Fact] + public void Process_ExistingLongerOutputFile_IsTruncated() + { + // File.OpenWrite does not truncate: without cutting off, the rest of a longer earlier file stayed behind. + string path = Path.Combine(Path.GetTempPath(), "templify-odt-openwrite-" + Guid.NewGuid().ToString("N") + ".odt"); + try + { + File.WriteAllBytes(path, new byte[200 * 1024]); + byte[] template = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes(); + ProcessingResult result; + using (FileStream output = File.OpenWrite(path)) + { + result = new OdtTemplateProcessor().ProcessTemplate(new MemoryStream(template), output, _data); + } + + Assert.True(result.IsSuccess, result.ErrorMessage); + byte[] written = File.ReadAllBytes(path); + Assert.True(written.Length < 200 * 1024); + OdtDocumentVerifier verifier = new OdtDocumentVerifier(written); + verifier.AssertValidOdtPackage(); + Assert.Equal("Hello World", verifier.GetParagraphTexts()[0]); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Process_SeekableOutputWithPrefix_KeepsPrefixAndCutsOffTheRest() + { + byte[] template = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes(); + using MemoryStream output = new MemoryStream(); + output.Write(new byte[] { 1, 2, 3 }); + output.Write(new byte[100 * 1024]); + output.Position = 3; + + ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(new MemoryStream(template), output, _data); + + Assert.True(result.IsSuccess, result.ErrorMessage); + Assert.Equal(output.Position, output.Length); + byte[] written = output.ToArray(); + Assert.Equal(new byte[] { 1, 2, 3 }, written[..3]); + Assert.Equal("Hello World", new OdtDocumentVerifier(written[3..]).GetParagraphTexts()[0]); + } + + private static byte[] CreateTemplateWithLargeEntry(out int entrySize) + { + entrySize = 64 * 1024 * 1024; + return new OdtDocumentBuilder() + .AddParagraph("{{Name}}") + .AddEntry("Pictures/big.bin", new byte[entrySize]) + .ToBytes(); + } + + private static void AssertLargeEntryCopied(byte[] output, int entrySize) + { + using ZipArchive archive = new ZipArchive(new MemoryStream(output), ZipArchiveMode.Read); + Assert.Equal(entrySize, archive.GetEntry("Pictures/big.bin")!.Length); + Assert.True(output.Length < entrySize / 100, $"Output of {output.Length:N0} bytes is not compressed."); + Assert.Equal("World", ReadParagraphText(archive)); + } + + private static string ReadParagraphText(ZipArchive archive) + { + using Stream content = archive.GetEntry("content.xml")!.Open(); + System.Xml.Linq.XDocument document = System.Xml.Linq.XDocument.Load(content); + return document.Descendants(OdtDocumentVerifier.Text + "p").Single().Value; + } + private static byte[] CreateZip(params (string Name, string Content)[] entries) { using MemoryStream stream = new MemoryStream(); diff --git a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs index 4d17f83..0def0d5 100644 --- a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs +++ b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs @@ -403,6 +403,14 @@ private ProcessingResult ProcessTemplateCore( } templateStream.CopyTo(outputStream); + + // An output with longer earlier content (an existing file opened without truncation) would keep + // trailing bytes after the copy, and the package could not be opened. + if (outputStream.Length > outputStream.Position) + { + outputStream.SetLength(outputStream.Position); + } + outputStream.Position = 0; // Track missing variables and warnings diff --git a/TriasDev.Templify/Core/OdtTemplateProcessor.cs b/TriasDev.Templify/Core/OdtTemplateProcessor.cs index 4a6ddac..4067c51 100644 --- a/TriasDev.Templify/Core/OdtTemplateProcessor.cs +++ b/TriasDev.Templify/Core/OdtTemplateProcessor.cs @@ -68,7 +68,8 @@ public OdtTemplateProcessor(PlaceholderReplacementOptions? options = null) /// Stream containing the template .odt or .ott file. Must be readable. /// /// Stream to write the processed .odt document to. Must be writable. The document is built in memory and - /// written only when processing succeeds; on failure nothing is written. + /// written only when processing succeeds; on failure nothing is written. A seekable stream is cut off after the + /// written document. /// /// Dictionary containing variable names and their replacement values. /// diff --git a/TriasDev.Templify/OpenDocument/OdtPackage.cs b/TriasDev.Templify/OpenDocument/OdtPackage.cs index 675817d..e1c8e2d 100644 --- a/TriasDev.Templify/OpenDocument/OdtPackage.cs +++ b/TriasDev.Templify/OpenDocument/OdtPackage.cs @@ -25,11 +25,17 @@ public InvalidOdtPackageException(string message, Exception innerException) } /// -/// An OpenDocument Text package (.odt or .ott) held in memory: its entries in their original -/// order, with XML parts loaded on demand and written back only when they changed. +/// An OpenDocument Text package (.odt or .ott): its entries in their original order, with the XML parts +/// that processing reads held in memory and written back only when they changed. /// /// /// +/// Only the mimetype entry and the XML parts processing reads (content.xml, styles.xml, +/// meta.xml and the manifest) are inflated into memory, each up to a maximum size. All other entries +/// (pictures, embedded objects, settings) are streamed from the source package into the output when it is +/// saved, so memory use follows the compressed package size, not its inflated size. +/// +/// /// The written package always starts with the mimetype entry, stored (uncompressed) and /// without an extra field, as ODF requires. A template (.ott) is written as a document (.odt): /// its mimetype and the manifest's root media type are rewritten. @@ -60,6 +66,26 @@ internal sealed class OdtPackage /// Name of the document signature part, which processing invalidates. public const string DocumentSignaturesEntry = "META-INF/documentsignatures.xml"; + /// + /// Default upper bound for the inflated size of each XML part that is loaded (256 MB). Real parts are far + /// smaller; the bound stops a crafted, highly compressed part from exhausting memory. + /// + public const long DefaultMaxXmlPartBytes = 256L * 1024 * 1024; + + /// Upper bound for the number of entries in a package (the classic ZIP limit). + public const int MaxEntryCount = 65535; + + /// Upper bound for the mimetype entry; the media type is a short ASCII string. + private const long MaxMimetypeBytes = 1024; + + private static readonly HashSet _loadedParts = new HashSet(StringComparer.Ordinal) + { + ContentEntry, + StylesEntry, + MetaEntry, + ManifestEntry, + }; + private static readonly XmlReaderSettings _readerSettings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, @@ -68,11 +94,13 @@ internal sealed class OdtPackage CloseInput = false, }; + private readonly Stream _source; private readonly List _entries; private readonly Dictionary _parts = new Dictionary(StringComparer.Ordinal); - private OdtPackage(List entries, string mediaType) + private OdtPackage(Stream source, List entries, string mediaType) { + _source = source; _entries = entries; MediaType = mediaType; } @@ -84,22 +112,51 @@ private OdtPackage(List entries, string mediaType) public bool IsTemplate => MediaType == OdfNames.TextTemplateMediaType; /// - /// Reads a package from a stream. + /// Reads a package from a stream. A seekable stream is read from position 0 and read again by + /// , so it must stay open and unchanged until then; a non-seekable stream is buffered + /// (compressed, as it is) in memory. /// - /// The stream is not an OpenDocument Text package. - public static OdtPackage Open(Stream stream) + /// The package. + /// Upper bound for the inflated size of each loaded XML part. + /// The stream is not a usable OpenDocument Text package. + public static OdtPackage Open(Stream stream, long maxXmlPartBytes = DefaultMaxXmlPartBytes) { + Stream source = stream; + if (!stream.CanSeek) + { + MemoryStream buffer = new MemoryStream(); + stream.CopyTo(buffer); + source = buffer; + } + List entries = new List(); + byte[]? mimetypeData = null; try { - using ZipArchive archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); - foreach (ZipArchiveEntry entry in archive.Entries) + source.Position = 0; + using ZipArchive archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true); + if (archive.Entries.Count > MaxEntryCount) { - using Stream entryStream = entry.Open(); - using MemoryStream buffer = new MemoryStream(); - entryStream.CopyTo(buffer); - entries.Add(new PackageEntry(entry.FullName, buffer.ToArray(), entry.LastWriteTime)); + throw new InvalidOdtPackageException( + $"Invalid document: the package has {archive.Entries.Count} entries, more than the maximum " + + $"supported number of {MaxEntryCount}."); + } + + for (int index = 0; index < archive.Entries.Count; index++) + { + ZipArchiveEntry entry = archive.Entries[index]; + byte[]? data = null; + if (entry.FullName == MimetypeEntry) + { + mimetypeData ??= ReadBounded(entry, MaxMimetypeBytes); + } + else if (_loadedParts.Contains(entry.FullName)) + { + data = ReadBounded(entry, maxXmlPartBytes); + } + + entries.Add(new PackageEntry(entry.FullName, index, data, entry.LastWriteTime)); } } catch (InvalidDataException ex) @@ -110,8 +167,8 @@ public static OdtPackage Open(Stream stream) ex); } - string mediaType = DetermineMediaType(entries); - OdtPackage package = new OdtPackage(entries, mediaType); + string mediaType = DetermineMediaType(mimetypeData, entries); + OdtPackage package = new OdtPackage(source, entries, mediaType); package.EnsureNotEncrypted(); if (package.FindEntry(ContentEntry) == null) @@ -123,7 +180,8 @@ public static OdtPackage Open(Stream stream) } /// - /// Gets an XML part, loading it on first access; null if the package has no such entry. + /// Gets an XML part, parsing it on first access; null if the package has no such entry. + /// Only content.xml, styles.xml, meta.xml, the manifest and added parts are XML parts. /// /// The part is not well-formed XML. public XDocument? GetXml(string entryName) @@ -139,12 +197,15 @@ public static OdtPackage Open(Stream stream) return null; } + if (entry.Data == null) + { + throw new InvalidOperationException($"The package entry {entryName} is not loaded as an XML part."); + } + XDocument document; try { - using MemoryStream input = new MemoryStream(entry.Data, writable: false); - using XmlReader reader = XmlReader.Create(input, _readerSettings); - document = XDocument.Load(reader, LoadOptions.PreserveWhitespace); + document = LoadXml(entry.Data); } catch (XmlException ex) { @@ -162,7 +223,7 @@ public static OdtPackage Open(Stream stream) /// public XDocument AddXml(string entryName, XDocument document) { - _entries.Add(new PackageEntry(entryName, Array.Empty(), DateTimeOffset.Now)); + _entries.Add(new PackageEntry(entryName, SourceIndex: -1, Array.Empty(), DateTimeOffset.Now)); LoadedPart part = new LoadedPart(document) { IsChanged = true }; document.Changed += (_, _) => part.IsChanged = true; _parts[entryName] = part; @@ -184,7 +245,8 @@ public XDocument AddXml(string entryName, XDocument document) } /// - /// Writes the package as an OpenDocument Text document (.odt). + /// Writes the package as an OpenDocument Text document (.odt) at the output's current position. A seekable + /// output is truncated after the package, so no bytes of longer earlier content remain. /// public void Save(Stream output) { @@ -195,9 +257,12 @@ public void Save(Stream output) RemoveDocumentSignatures(); - // Build in memory: ZipArchive writes local headers without data descriptors only on a - // seekable stream, and the output is written only once everything succeeded. + // Build the package in memory: ZipArchive writes local headers without data descriptors only on a + // seekable stream, and the output is written only once everything succeeded. Entries that are not + // loaded are streamed from the source archive, so the buffer holds compressed data only. using MemoryStream buffer = new MemoryStream(); + _source.Position = 0; + using (ZipArchive source = new ZipArchive(_source, ZipArchiveMode.Read, leaveOpen: true)) using (ZipArchive archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) { PackageEntry? sourceMimetype = FindEntry(MimetypeEntry); @@ -222,12 +287,65 @@ public void Save(Stream output) ZipArchiveEntry target = archive.CreateEntry(entry.Name, CompressionLevel.Optimal); SetLastWriteTime(target, entry.LastWriteTime); using Stream stream = target.Open(); - stream.Write(GetEntryData(entry)); + byte[]? data = GetEntryData(entry); + if (data != null) + { + stream.Write(data); + } + else + { + using Stream sourceStream = source.Entries[entry.SourceIndex].Open(); + sourceStream.CopyTo(stream); + } } } buffer.Position = 0; buffer.CopyTo(output); + TruncateAfterPosition(output); + } + + /// + /// Serializes an XML part as UTF-8 without BOM, unindented, with line breaks entitized. + /// + internal static byte[] Serialize(XDocument document) + { + XmlWriterSettings settings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Indent = false, + NewLineHandling = NewLineHandling.Entitize, + OmitXmlDeclaration = false, + }; + + using MemoryStream stream = new MemoryStream(); + using (XmlWriter writer = XmlWriter.Create(stream, settings)) + { + document.Save(writer); + } + + return stream.ToArray(); + } + + /// + /// Cuts off what follows the written package in a seekable output, e.g. the rest of a longer file opened with + /// . + /// + private static void TruncateAfterPosition(Stream output) + { + if (!output.CanSeek || output.Length <= output.Position) + { + return; + } + + try + { + output.SetLength(output.Position); + } + catch (NotSupportedException) + { + // A seekable stream with a fixed length (e.g. a MemoryStream over a byte array): nothing to cut off with. + } } private static void SetLastWriteTime(ZipArchiveEntry entry, DateTimeOffset time) @@ -239,45 +357,68 @@ private static void SetLastWriteTime(ZipArchiveEntry entry, DateTimeOffset time) } } - private byte[] GetEntryData(PackageEntry entry) + /// + /// Inflates an entry into memory; fails when its declared or actual size exceeds + /// (the declared size in the ZIP header can be wrong, so the inflated bytes are counted as well). + /// + private static byte[] ReadBounded(ZipArchiveEntry entry, long maxBytes) { - if (!_parts.TryGetValue(entry.Name, out LoadedPart? part) || !part.IsChanged) + if (entry.Length > maxBytes) { - return entry.Data; + throw CreateTooLargeException(entry.FullName, maxBytes); } - return Serialize(part.Document); + using Stream input = entry.Open(); + using MemoryStream buffer = new MemoryStream((int)Math.Min(entry.Length, 1024 * 1024)); + byte[] chunk = new byte[81920]; + int read; + while ((read = input.Read(chunk, 0, chunk.Length)) > 0) + { + if (buffer.Length + read > maxBytes) + { + throw CreateTooLargeException(entry.FullName, maxBytes); + } + + buffer.Write(chunk, 0, read); + } + + return buffer.ToArray(); + } + + private static InvalidOdtPackageException CreateTooLargeException(string entryName, long maxBytes) + { + string limit = maxBytes >= 1024 * 1024 ? $"{maxBytes / (1024 * 1024)} MB" : $"{maxBytes} bytes"; + return new InvalidOdtPackageException( + $"Invalid document: {entryName} exceeds the maximum supported size ({limit} uncompressed)."); + } + + private static XDocument LoadXml(byte[] data) + { + using MemoryStream input = new MemoryStream(data, writable: false); + using XmlReader reader = XmlReader.Create(input, _readerSettings); + return XDocument.Load(reader, LoadOptions.PreserveWhitespace); } /// - /// Serializes an XML part as UTF-8 without BOM, unindented, with line breaks entitized. + /// The bytes to write for an entry: the serialized part if it changed, else its loaded bytes; null for an + /// entry that is copied from the source package. /// - internal static byte[] Serialize(XDocument document) + private byte[]? GetEntryData(PackageEntry entry) { - XmlWriterSettings settings = new XmlWriterSettings - { - Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - Indent = false, - NewLineHandling = NewLineHandling.Entitize, - OmitXmlDeclaration = false, - }; - - using MemoryStream stream = new MemoryStream(); - using (XmlWriter writer = XmlWriter.Create(stream, settings)) + if (!_parts.TryGetValue(entry.Name, out LoadedPart? part) || !part.IsChanged) { - document.Save(writer); + return entry.Data; } - return stream.ToArray(); + return Serialize(part.Document); } private PackageEntry? FindEntry(string name) => _entries.FirstOrDefault(e => string.Equals(e.Name, name, StringComparison.Ordinal)); - private static string DetermineMediaType(List entries) + private static string DetermineMediaType(byte[]? mimetypeData, List entries) { - PackageEntry? mimetype = entries.FirstOrDefault(e => e.Name == MimetypeEntry); - string? mediaType = mimetype != null ? Encoding.ASCII.GetString(mimetype.Data).Trim() : null; + string? mediaType = mimetypeData != null ? Encoding.ASCII.GetString(mimetypeData).Trim() : null; if (string.IsNullOrEmpty(mediaType)) { @@ -297,18 +438,15 @@ private static string DetermineMediaType(List entries) private static string? ReadManifestRootMediaType(List entries) { - PackageEntry? manifestEntry = entries.FirstOrDefault(e => e.Name == ManifestEntry); - if (manifestEntry == null) + byte[]? manifestData = entries.FirstOrDefault(e => e.Name == ManifestEntry)?.Data; + if (manifestData == null) { return null; } try { - using MemoryStream input = new MemoryStream(manifestEntry.Data, writable: false); - using XmlReader reader = XmlReader.Create(input, _readerSettings); - XDocument manifest = XDocument.Load(reader); - return FindManifestRoot(manifest)?.Attribute(OdfNames.ManifestMediaType)?.Value; + return FindManifestRoot(LoadXml(manifestData))?.Attribute(OdfNames.ManifestMediaType)?.Value; } catch (XmlException) { @@ -362,7 +500,11 @@ private void RemoveDocumentSignatures() .Remove(); } - private sealed record PackageEntry(string Name, byte[] Data, DateTimeOffset LastWriteTime); + /// + /// An entry of the package. holds the bytes of a loaded or added part; any other entry + /// is copied from the source archive entry at (-1 for added parts). + /// + private sealed record PackageEntry(string Name, int SourceIndex, byte[]? Data, DateTimeOffset LastWriteTime); private sealed class LoadedPart { diff --git a/docs/for-developers/opendocument.md b/docs/for-developers/opendocument.md index bb52261..7323f55 100644 --- a/docs/for-developers/opendocument.md +++ b/docs/for-developers/opendocument.md @@ -68,6 +68,22 @@ The requirements are looser than for Word documents: A template that is not an OpenDocument Text package is reported as a failed result, with an `ErrorMessage` that names what was found. This covers a Word file, a spreadsheet, a flat `.fodt` file or a password-protected document. +### Memory Use + +Only the parts that processing reads are unpacked into memory: `content.xml`, `styles.xml`, `meta.xml` and +`META-INF/manifest.xml`. Pictures, embedded objects and all other entries are copied from the template into the +output entry by entry, without holding them in memory. The output package is built in memory in compressed form (so +that nothing is written on failure), and a template stream that is not seekable is buffered in compressed form. Memory +use therefore follows the size of the `.odt` file plus the size of its XML parts, not the unpacked size of its pictures. + +Each XML part may be at most 256 MB when unpacked, and a package at most 65,535 entries. A template that exceeds +these limits fails with an `ErrorMessage` such as `content.xml exceeds the maximum supported size`. Real documents +are far below these limits. Treat templates from untrusted sources with care anyway: an entry that unpacks to a very +large size is not held in memory, but it still costs time to copy. + +When the output stream is seekable (a file or a `MemoryStream`), it is cut off after the written document, so an +existing, longer file opened with `File.OpenWrite` does not keep bytes of its earlier content. + ### Options All `PlaceholderReplacementOptions` apply, with two notes: diff --git a/docs/superpowers/specs/2026-09-26-odt-support-design.md b/docs/superpowers/specs/2026-09-26-odt-support-design.md index 66614b1..c67217a 100644 --- a/docs/superpowers/specs/2026-09-26-odt-support-design.md +++ b/docs/superpowers/specs/2026-09-26-odt-support-design.md @@ -134,7 +134,7 @@ This mirrors `DocumentWalker.WalkElements`/`WalkRows`, including the #140 rule: Contract differences from `DocumentTemplateProcessor`, all of them relaxations: -- The output stream only has to be **writable**. The document is built in memory and written in one go when processing succeeds. The DOCX processor edits in place and needs a readable, writable and seekable stream. +- The output stream only has to be **writable**. The document is built in memory (compressed; only `content.xml`, `styles.xml`, `meta.xml` and the manifest are unpacked, each up to 256 MB, other entries are streamed from the template) and written in one go when processing succeeds. A seekable output is cut off after the document. The DOCX processor edits in place and needs a readable, writable and seekable stream. - On failure nothing is written to the output stream. Options that do not apply to ODT: