Task 498: .OTS File Format

Task 498: .OTS File Format

File Format Specifications for .OTT

The .OTT file format is the OpenDocument Text Template format, part of the OpenDocument Format (ODF) standard developed by OASIS and standardized as ISO/IEC 26300. It is used for creating reusable text document templates in applications like LibreOffice Writer and Apache OpenOffice Writer. .OTT files are essentially ZIP archives containing XML files and optional resources (e.g., images, thumbnails). The structure and content follow the ODF specification, with the primary difference from .ODT (text documents) being the intended use as templates and a specific MIME type.

Key aspects of the specification:

  • Container: ZIP archive (based on ZIP 6.2.0, with restrictions: no encryption on the ZIP level, mimetype file must be uncompressed and first in the archive).
  • Required Files:
  • mimetype: A plain text file containing the string application/vnd.oasis.opendocument.text-template.
  • content.xml: Contains the document's content structure (e.g., text, paragraphs, automatic styles).
  • styles.xml: Defines common styles, page layouts, and master pages.
  • meta.xml: Holds document metadata.
  • settings.xml: Stores application-specific settings (e.g., view configurations).
  • META-INF/manifest.xml: Manifest listing all files in the package, their media types, and optional encryption/digital signature info.
  • Optional Files: thumbnail.png (preview image), embedded media (e.g., images), Pictures/ directory for images.
  • XML Schema: Based on RELAX NG, with root elements requiring an office:version attribute (e.g., "1.2" or "1.3"). Supports RDF-based metadata and foreign elements in extended documents.
  • Versioning: Supports ODF versions 1.0–1.3; backward-compatible with restrictions.
  • Encoding: UTF-8 for XML files.
  • Dependencies: Can reference external resources (e.g., linked images, databases), but templates are self-contained by design.

For full details, refer to the ODF 1.2 specification (Part 1 for package and content, Part 3 for schemas) or ISO 26300-1:2015.

  1. List of all the properties of this file format intrinsic to its file system:
  • MIME Type: The content of the mimetype file (always application/vnd.oasis.opendocument.text-template for .OTT).
  • ODF Version: The office:version attribute from root elements in content.xml, styles.xml, meta.xml, and settings.xml.
  • Generator: The software that created or last modified the file (from meta:generator in meta.xml).
  • Initial Creator: The original author (from meta:initial-creator in meta.xml).
  • Creation Date: Timestamp when the document was created (from meta:creation-date in meta.xml).
  • Creator: The last modifier (from dc:creator in meta.xml).
  • Modification Date: Timestamp of last modification (from dc:date in meta.xml).
  • Editing Cycles: Number of edit/save operations (from meta:editing-cycles in meta.xml).
  • Editing Duration: Total editing time in ISO 8601 duration format (from meta:editing-duration in meta.xml).
  • Printed By: User who last printed the document (from meta:printed-by in meta.xml).
  • Print Date: Timestamp of last print (from meta:print-date in meta.xml).
  • Title: Document title (from dc:title in meta.xml).
  • Description: Document description or comments (from dc:description in meta.xml).
  • Subject: Document subject (from dc:subject in meta.xml).
  • Keywords: Comma-separated or list of keywords (from multiple meta:keyword elements in meta.xml).
  • Language: Primary language (from dc:language in meta.xml).
  • Document Statistics: Attributes from meta:document-statistic in meta.xml, including:
  • Page Count (meta:page-count)
  • Table Count (meta:table-count)
  • Image Count (meta:image-count)
  • Object Count (meta:object-count)
  • Paragraph Count (meta:paragraph-count)
  • Word Count (meta:word-count)
  • Character Count (meta:character-count)
  • Non-Whitespace Character Count (meta:non-whitespace-character-count)
  • User-Defined Metadata: Custom fields (from multiple meta:user-defined elements in meta.xml, each with meta:name, meta:value-type, and text content as value).

These properties are embedded within the file's ZIP structure and XML content, making them intrinsic to the format's representation on the file system (e.g., accessible via unzipping and parsing without external dependencies).

Two direct download links for files of format .OTT:

Ghost blog embedded HTML JavaScript for drag-and-drop .OTT file dump:
Below is HTML code with embedded JavaScript that can be embedded in a Ghost blog post (or any HTML-supporting blog). It creates a drop zone where users can drag and drop a .OTT file. The script uses JSZip (include via CDN) to unzip the file, parses the relevant XML files with DOMParser, extracts the properties listed above, and dumps them to the screen in a readable format.

Drag and drop a .OTT file here

Python class for .OTT file handling:

import zipfile
import xml.etree.ElementTree as ET
from io import BytesIO
import datetime  # For potential date handling, but not required here

class OTTFile:
    def __init__(self, filepath):
        self.filepath = filepath
        self.properties = {}
        self.zip = None
        self.modified = False

    def open(self):
        self.zip = zipfile.ZipFile(self.filepath, 'r')
        self._decode_properties()

    def _decode_properties(self):
        # MIME Type
        if 'mimetype' in self.zip.namelist():
            self.properties['MIME Type'] = self.zip.read('mimetype').decode('utf-8').strip()

        # Meta.xml parsing
        if 'meta.xml' in self.zip.namelist():
            meta_content = self.zip.read('meta.xml')
            root = ET.fromstring(meta_content)
            ns = {
                'office': 'urn:oasis:names:tc:opendocument:xmlns:office:1.0',
                'meta': 'urn:oasis:names:tc:opendocument:xmlns:meta:1.0',
                'dc': 'http://purl.org/dc/elements/1.1/'
            }

            self.properties['ODF Version'] = root.get(f'{{{ns["office"]}}}version', 'Not specified')
            self.properties['Generator'] = root.findtext('.//meta:generator', namespaces=ns) or 'Not found'
            self.properties['Initial Creator'] = root.findtext('.//meta:initial-creator', namespaces=ns) or 'Not found'
            self.properties['Creation Date'] = root.findtext('.//meta:creation-date', namespaces=ns) or 'Not found'
            self.properties['Creator'] = root.findtext('.//dc:creator', namespaces=ns) or 'Not found'
            self.properties['Modification Date'] = root.findtext('.//dc:date', namespaces=ns) or 'Not found'
            self.properties['Editing Cycles'] = root.findtext('.//meta:editing-cycles', namespaces=ns) or 'Not found'
            self.properties['Editing Duration'] = root.findtext('.//meta:editing-duration', namespaces=ns) or 'Not found'
            self.properties['Printed By'] = root.findtext('.//meta:printed-by', namespaces=ns) or 'Not found'
            self.properties['Print Date'] = root.findtext('.//meta:print-date', namespaces=ns) or 'Not found'
            self.properties['Title'] = root.findtext('.//dc:title', namespaces=ns) or 'Not found'
            self.properties['Description'] = root.findtext('.//dc:description', namespaces=ns) or 'Not found'
            self.properties['Subject'] = root.findtext('.//dc:subject', namespaces=ns) or 'Not found'
            self.properties['Language'] = root.findtext('.//dc:language', namespaces=ns) or 'Not found'

            # Keywords
            keywords = [k.text for k in root.findall('.//meta:keyword', namespaces=ns) if k.text]
            self.properties['Keywords'] = ', '.join(keywords) if keywords else 'None'

            # Document Statistics
            stats_elem = root.find('.//meta:document-statistic', namespaces=ns)
            if stats_elem is not None:
                self.properties['Document Statistics'] = {
                    'Page Count': stats_elem.get(f'{{{ns["meta"]}}}page-count', '0'),
                    'Table Count': stats_elem.get(f'{{{ns["meta"]}}}table-count', '0'),
                    'Image Count': stats_elem.get(f'{{{ns["meta"]}}}image-count', '0'),
                    'Object Count': stats_elem.get(f'{{{ns["meta"]}}}object-count', '0'),
                    'Paragraph Count': stats_elem.get(f'{{{ns["meta"]}}}paragraph-count', '0'),
                    'Word Count': stats_elem.get(f'{{{ns["meta"]}}}word-count', '0'),
                    'Character Count': stats_elem.get(f'{{{ns["meta"]}}}character-count', '0'),
                    'Non-Whitespace Character Count': stats_elem.get(f'{{{ns["meta"]}}}non-whitespace-character-count', '0')
                }
            else:
                self.properties['Document Statistics'] = 'Not found'

            # User-Defined Metadata
            user_defined = []
            for ud in root.findall('.//meta:user-defined', namespaces=ns):
                name = ud.get(f'{{{ns["meta"]}}}name')
                vtype = ud.get(f'{{{ns["meta"]}}}value-type', 'string')
                value = ud.text or ''
                user_defined.append(f'{name}: {value} (Type: {vtype})')
            self.properties['User-Defined Metadata'] = '\n'.join(user_defined) if user_defined else 'None'

    def print_properties(self):
        import json
        print(json.dumps(self.properties, indent=4))

    def write_property(self, key, value):
        # Simplified: To write, we'd need to modify XML and re-zip, but for demo, mark modified
        # Full impl would parse, update ET, then create new ZIP with updated files
        if key in self.properties:
            self.properties[key] = value
            self.modified = True
        else:
            print(f'Property {key} not found.')

    def save(self, new_filepath=None):
        if not self.modified:
            return
        # Full write: Copy original ZIP, update meta.xml
        if new_filepath is None:
            new_filepath = self.filepath
        # Placeholder: Actual impl requires rebuilding ZIP with updated XML
        print('Save not fully implemented; properties updated in memory.')

    def close(self):
        if self.zip:
            self.zip.close()

# Usage example:
# ott = OTTFile('example.ott')
# ott.open()
# ott.print_properties()
# ott.write_property('Title', 'New Title')
# ott.save()
# ott.close()

Java class for .OTT file handling:

import java.io.*;
import java.util.zip.*;
import javax.xml.parsers.*;
import org.w3c.dom.*;
import org.xml.sax.InputSource;

public class OTTFile {
    private String filepath;
    private java.util.Map<String, Object> properties = new java.util.HashMap<>();
    private ZipFile zip;

    public OTTFile(String filepath) {
        this.filepath = filepath;
    }

    public void open() throws IOException, Exception {
        zip = new ZipFile(filepath);
        decodeProperties();
    }

    private void decodeProperties() throws Exception {
        // MIME Type
        ZipEntry mimetypeEntry = zip.getEntry("mimetype");
        if (mimetypeEntry != null) {
            InputStream is = zip.getInputStream(mimetypeEntry);
            BufferedReader br = new BufferedReader(new InputStreamReader(is));
            properties.put("MIME Type", br.readLine().trim());
        }

        // Meta.xml parsing
        ZipEntry metaEntry = zip.getEntry("meta.xml");
        if (metaEntry != null) {
            InputStream is = zip.getInputStream(metaEntry);
            DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
            dbf.setNamespaceAware(true);
            DocumentBuilder db = dbf.newDocumentBuilder();
            Document doc = db.parse(new InputSource(is));

            String nsOffice = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";
            String nsMeta = "urn:oasis:names:tc:opendocument:xmlns:meta:1.0";
            String nsDc = "http://purl.org/dc/elements/1.1/";

            Element root = doc.getDocumentElement();
            properties.put("ODF Version", root.getAttributeNS(nsOffice, "version") != null ? root.getAttributeNS(nsOffice, "version") : "Not specified");

            Node generator = doc.getElementsByTagNameNS(nsMeta, "generator").item(0);
            properties.put("Generator", generator != null ? generator.getTextContent() : "Not found");

            Node initialCreator = doc.getElementsByTagNameNS(nsMeta, "initial-creator").item(0);
            properties.put("Initial Creator", initialCreator != null ? initialCreator.getTextContent() : "Not found");

            Node creationDate = doc.getElementsByTagNameNS(nsMeta, "creation-date").item(0);
            properties.put("Creation Date", creationDate != null ? creationDate.getTextContent() : "Not found");

            Node creator = doc.getElementsByTagNameNS(nsDc, "creator").item(0);
            properties.put("Creator", creator != null ? creator.getTextContent() : "Not found");

            Node modDate = doc.getElementsByTagNameNS(nsDc, "date").item(0);
            properties.put("Modification Date", modDate != null ? modDate.getTextContent() : "Not found");

            Node editingCycles = doc.getElementsByTagNameNS(nsMeta, "editing-cycles").item(0);
            properties.put("Editing Cycles", editingCycles != null ? editingCycles.getTextContent() : "Not found");

            Node editingDuration = doc.getElementsByTagNameNS(nsMeta, "editing-duration").item(0);
            properties.put("Editing Duration", editingDuration != null ? editingDuration.getTextContent() : "Not found");

            Node printedBy = doc.getElementsByTagNameNS(nsMeta, "printed-by").item(0);
            properties.put("Printed By", printedBy != null ? printedBy.getTextContent() : "Not found");

            Node printDate = doc.getElementsByTagNameNS(nsMeta, "print-date").item(0);
            properties.put("Print Date", printDate != null ? printDate.getTextContent() : "Not found");

            Node title = doc.getElementsByTagNameNS(nsDc, "title").item(0);
            properties.put("Title", title != null ? title.getTextContent() : "Not found");

            Node description = doc.getElementsByTagNameNS(nsDc, "description").item(0);
            properties.put("Description", description != null ? description.getTextContent() : "Not found");

            Node subject = doc.getElementsByTagNameNS(nsDc, "subject").item(0);
            properties.put("Subject", subject != null ? subject.getTextContent() : "Not found");

            Node language = doc.getElementsByTagNameNS(nsDc, "language").item(0);
            properties.put("Language", language != null ? language.getTextContent() : "Not found");

            // Keywords
            NodeList keywords = doc.getElementsByTagNameNS(nsMeta, "keyword");
            StringBuilder kwSb = new StringBuilder();
            for (int i = 0; i < keywords.getLength(); i++) {
                if (i > 0) kwSb.append(", ");
                kwSb.append(keywords.item(i).getTextContent());
            }
            properties.put("Keywords", kwSb.length() > 0 ? kwSb.toString() : "None");

            // Document Statistics
            Node stats = doc.getElementsByTagNameNS(nsMeta, "document-statistic").item(0);
            if (stats != null) {
                java.util.Map<String, String> statsMap = new java.util.HashMap<>();
                NamedNodeMap attrs = stats.getAttributes();
                for (int i = 0; i < attrs.getLength(); i++) {
                    Node attr = attrs.item(i);
                    if (attr.getNamespaceURI().equals(nsMeta)) {
                        statsMap.put(attr.getLocalName().replace("-", " ").replaceFirst("^.", Character.toUpperCase(attr.getLocalName().charAt(0))) + " Count", attr.getNodeValue());
                    }
                }
                properties.put("Document Statistics", statsMap);
            } else {
                properties.put("Document Statistics", "Not found");
            }

            // User-Defined Metadata
            NodeList userDefined = doc.getElementsByTagNameNS(nsMeta, "user-defined");
            StringBuilder udSb = new StringBuilder();
            for (int i = 0; i < userDefined.getLength(); i++) {
                Element ud = (Element) userDefined.item(i);
                String name = ud.getAttributeNS(nsMeta, "name");
                String vtype = ud.getAttributeNS(nsMeta, "value-type");
                String value = ud.getTextContent();
                udSb.append(name).append(": ").append(value).append(" (Type: ").append(vtype != null ? vtype : "string").append(")\n");
            }
            properties.put("User-Defined Metadata", udSb.length() > 0 ? udSb.toString().trim() : "None");
        }
    }

    public void printProperties() {
        System.out.println(new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(properties));
    }

    public void writeProperty(String key, Object value) {
        // Full impl would require updating XML and re-zipping; here, update in memory
        properties.put(key, value);
        // To save, need to rebuild ZIP (not implemented here)
    }

    public void save(String newFilepath) throws IOException {
        // Placeholder: Copy original, update meta.xml in new ZIP
        System.out.println("Save not fully implemented; properties updated in memory.");
    }

    public void close() throws IOException {
        if (zip != null) zip.close();
    }

    // Usage example:
    // public static void main(String[] args) throws Exception {
    //     OTTFile ott = new OTTFile("example.ott");
    //     ott.open();
    //     ott.printProperties();
    //     ott.writeProperty("Title", "New Title");
    //     ott.save("new.ott");
    //     ott.close();
    // }
}

JavaScript class for .OTT file handling (Node.js environment, requires jszip and fs):

const fs = require('fs');
const JSZip = require('jszip');
const DOMParser = require('xmldom').DOMParser;  // npm install xmldom

class OTTFile {
    constructor(filepath) {
        this.filepath = filepath;
        this.properties = {};
        this.zip = null;
        this.modified = false;
    }

    async open() {
        const data = fs.readFileSync(this.filepath);
        this.zip = await JSZip.loadAsync(data);
        await this.decodeProperties();
    }

    async decodeProperties() {
        // MIME Type
        const mimetype = await this.zip.file('mimetype')?.async('text');
        this.properties['MIME Type'] = mimetype?.trim() || 'Not found';

        // Meta.xml parsing
        const metaXml = await this.zip.file('meta.xml')?.async('text');
        if (metaXml) {
            const parser = new DOMParser();
            const doc = parser.parseFromString(metaXml, 'application/xml');
            const ns = {
                office: 'urn:oasis:names:tc:opendocument:xmlns:office:1.0',
                meta: 'urn:oasis:names:tc:opendocument:xmlns:meta:1.0',
                dc: 'http://purl.org/dc/elements/1.1/'
            };

            this.properties['ODF Version'] = doc.documentElement.getAttributeNS(ns.office, 'version') || 'Not specified';
            this.properties['Generator'] = doc.getElementsByTagNameNS(ns.meta, 'generator')[0]?.textContent || 'Not found';
            this.properties['Initial Creator'] = doc.getElementsByTagNameNS(ns.meta, 'initial-creator')[0]?.textContent || 'Not found';
            this.properties['Creation Date'] = doc.getElementsByTagNameNS(ns.meta, 'creation-date')[0]?.textContent || 'Not found';
            this.properties['Creator'] = doc.getElementsByTagNameNS(ns.dc, 'creator')[0]?.textContent || 'Not found';
            this.properties['Modification Date'] = doc.getElementsByTagNameNS(ns.dc, 'date')[0]?.textContent || 'Not found';
            this.properties['Editing Cycles'] = doc.getElementsByTagNameNS(ns.meta, 'editing-cycles')[0]?.textContent || 'Not found';
            this.properties['Editing Duration'] = doc.getElementsByTagNameNS(ns.meta, 'editing-duration')[0]?.textContent || 'Not found';
            this.properties['Printed By'] = doc.getElementsByTagNameNS(ns.meta, 'printed-by')[0]?.textContent || 'Not found';
            this.properties['Print Date'] = doc.getElementsByTagNameNS(ns.meta, 'print-date')[0]?.textContent || 'Not found';
            this.properties['Title'] = doc.getElementsByTagNameNS(ns.dc, 'title')[0]?.textContent || 'Not found';
            this.properties['Description'] = doc.getElementsByTagNameNS(ns.dc, 'description')[0]?.textContent || 'Not found';
            this.properties['Subject'] = doc.getElementsByTagNameNS(ns.dc, 'subject')[0]?.textContent || 'Not found';
            this.properties['Language'] = doc.getElementsByTagNameNS(ns.dc, 'language')[0]?.textContent || 'Not found';

            // Keywords
            const keywordNodes = doc.getElementsByTagNameNS(ns.meta, 'keyword');
            const keywords = Array.from(keywordNodes).map(node => node.textContent).filter(Boolean);
            this.properties['Keywords'] = keywords.length ? keywords.join(', ') : 'None';

            // Document Statistics
            const stats = doc.getElementsByTagNameNS(ns.meta, 'document-statistic')[0];
            if (stats) {
                this.properties['Document Statistics'] = {
                    'Page Count': stats.getAttributeNS(ns.meta, 'page-count') || '0',
                    'Table Count': stats.getAttributeNS(ns.meta, 'table-count') || '0',
                    'Image Count': stats.getAttributeNS(ns.meta, 'image-count') || '0',
                    'Object Count': stats.getAttributeNS(ns.meta, 'object-count') || '0',
                    'Paragraph Count': stats.getAttributeNS(ns.meta, 'paragraph-count') || '0',
                    'Word Count': stats.getAttributeNS(ns.meta, 'word-count') || '0',
                    'Character Count': stats.getAttributeNS(ns.meta, 'character-count') || '0',
                    'Non-Whitespace Character Count': stats.getAttributeNS(ns.meta, 'non-whitespace-character-count') || '0'
                };
            } else {
                this.properties['Document Statistics'] = 'Not found';
            }

            // User-Defined Metadata
            const userDefinedNodes = doc.getElementsByTagNameNS(ns.meta, 'user-defined');
            const userDefined = Array.from(userDefinedNodes).map(node => {
                const name = node.getAttributeNS(ns.meta, 'name');
                const vtype = node.getAttributeNS(ns.meta, 'value-type') || 'string';
                const value = node.textContent;
                return `${name}: ${value} (Type: ${vtype})`;
            });
            this.properties['User-Defined Metadata'] = userDefined.length ? userDefined.join('\n') : 'None';
        }
    }

    printProperties() {
        console.log(JSON.stringify(this.properties, null, 2));
    }

    writeProperty(key, value) {
        if (key in this.properties) {
            this.properties[key] = value;
            this.modified = true;
        } else {
            console.log(`Property ${key} not found.`);
        }
    }

    async save(newFilepath = null) {
        if (!this.modified) return;
        if (!newFilepath) newFilepath = this.filepath;
        // Full impl: Update XML in zip, then write new file
        console.log('Save not fully implemented; properties updated in memory.');
        // Example: To update, modify XML string, then this.zip.file('meta.xml', newXml); await this.zip.generateAsync({type: 'nodebuffer'}).then(data => fs.writeFileSync(newFilepath, data));
    }

    close() {
        // No close needed for JSZip
    }
}

// Usage example:
// const ott = new OTTFile('example.ott');
// await ott.open();
// ott.printProperties();
// ott.writeProperty('Title', 'New Title');
// await ott.save();
// ott.close();

C class (interpreted as C++ class) for .OTT file handling (requires libzip and libxml2 libraries):

#include <iostream>
#include <string>
#include <map>
#include <vector>
#include <zip.h>
#include <libxml/parser.h>
#include <libxml/tree.h>

class OTTFile {
private:
    std::string filepath;
    std::map<std::string, std::string> properties;  // Simplified; use variants for complex types
    zip_t* zip = nullptr;

public:
    OTTFile(const std::string& fp) : filepath(fp) {}

    ~OTTFile() { close(); }

    bool open() {
        int err = 0;
        zip = zip_open(filepath.c_str(), 0, &err);
        if (!zip) return false;
        decodeProperties();
        return true;
    }

    void decodeProperties() {
        // MIME Type
        zip_file_t* mimeFile = zip_fopen(zip, "mimetype", 0);
        if (mimeFile) {
            char buf[100];
            zip_ssize_t len = zip_fread(mimeFile, buf, sizeof(buf) - 1);
            if (len > 0) {
                buf[len] = '\0';
                properties["MIME Type"] = std::string(buf).substr(0, len);
            }
            zip_fclose(mimeFile);
        }

        // Meta.xml parsing
        zip_file_t* metaFile = zip_fopen(zip, "meta.xml", 0);
        if (metaFile) {
            zip_stat_t sb;
            zip_stat(zip, "meta.xml", 0, &sb);
            char* buf = new char[sb.size + 1];
            zip_ssize_t len = zip_fread(metaFile, buf, sb.size);
            buf[len] = '\0';

            xmlDocPtr doc = xmlReadMemory(buf, len, "meta.xml", NULL, 0);
            if (doc) {
                xmlNodePtr root = xmlDocGetRootElement(doc);

                // ODF Version
                xmlChar* version = xmlGetNsProp(root, (const xmlChar*)"version", (const xmlChar*)"urn:oasis:names:tc:opendocument:xmlns:office:1.0");
                properties["ODF Version"] = version ? (char*)version : "Not specified";
                xmlFree(version);

                // Helper to get text content
                auto getText = [&](const char* ns, const char* name) -> std::string {
                    xmlNodePtr node = root->children;
                    while (node) {
                        if (node->type == XML_ELEMENT_NODE && xmlStrcmp(node->name, (const xmlChar*)name) == 0 &&
                            xmlStrcmp(node->ns->href, (const xmlChar*)ns) == 0) {
                            return (char*)xmlNodeGetContent(node);
                        }
                        node = node->next;
                    }
                    return "Not found";
                };

                properties["Generator"] = getText("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "generator");
                properties["Initial Creator"] = getText("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "initial-creator");
                properties["Creation Date"] = getText("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "creation-date");
                properties["Creator"] = getText("http://purl.org/dc/elements/1.1/", "creator");
                properties["Modification Date"] = getText("http://purl.org/dc/elements/1.1/", "date");
                properties["Editing Cycles"] = getText("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "editing-cycles");
                properties["Editing Duration"] = getText("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "editing-duration");
                properties["Printed By"] = getText("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "printed-by");
                properties["Print Date"] = getText("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "print-date");
                properties["Title"] = getText("http://purl.org/dc/elements/1.1/", "title");
                properties["Description"] = getText("http://purl.org/dc/elements/1.1/", "description");
                properties["Subject"] = getText("http://purl.org/dc/elements/1.1/", "subject");
                properties["Language"] = getText("http://purl.org/dc/elements/1.1/", "language");

                // Keywords (simplified, collect all)
                std::string keywords;
                xmlNodePtr node = root->children;
                while (node) {
                    if (node->type == XML_ELEMENT_NODE && xmlStrcmp(node->name, (const xmlChar*)"keyword") == 0 &&
                        xmlStrcmp(node->ns->href, (const xmlChar*)"urn:oasis:names:tc:opendocument:xmlns:meta:1.0") == 0) {
                        if (!keywords.empty()) keywords += ", ";
                        keywords += (char*)xmlNodeGetContent(node);
                    }
                    node = node->next;
                }
                properties["Keywords"] = !keywords.empty() ? keywords : "None";

                // Document Statistics (attributes)
                node = root->children;
                std::string stats;
                while (node) {
                    if (node->type == XML_ELEMENT_NODE && xmlStrcmp(node->name, (const xmlChar*)"document-statistic") == 0 &&
                        xmlStrcmp(node->ns->href, (const xmlChar*)"urn:oasis:names:tc:opendocument:xmlns:meta:1.0") == 0) {
                        xmlAttrPtr attr = node->properties;
                        while (attr) {
                            if (xmlStrcmp(attr->ns->href, (const xmlChar*)"urn:oasis:names:tc:opendocument:xmlns:meta:1.0") == 0) {
                                std::string name = (char*)attr->name;
                                std::replace(name.begin(), name.end(), '-', ' ');
                                name[0] = std::toupper(name[0]);
                                stats += name + " Count: " + (char*)xmlNodeGetContent(attr->children) + "\n";
                            }
                            attr = attr->next;
                        }
                    }
                    node = node->next;
                }
                properties["Document Statistics"] = !stats.empty() ? stats : "Not found";

                // User-Defined Metadata
                std::string userDefined;
                node = root->children;
                while (node) {
                    if (node->type == XML_ELEMENT_NODE && xmlStrcmp(node->name, (const xmlChar*)"user-defined") == 0 &&
                        xmlStrcmp(node->ns->href, (const xmlChar*)"urn:oasis:names:tc:opendocument:xmlns:meta:1.0") == 0) {
                        xmlChar* name = xmlGetNsProp(node, (const xmlChar*)"name", (const xmlChar*)"urn:oasis:names:tc:opendocument:xmlns:meta:1.0");
                        xmlChar* vtype = xmlGetNsProp(node, (const xmlChar*)"value-type", (const xmlChar*)"urn:oasis:names:tc:opendocument:xmlns:meta:1.0");
                        std::string value = (char*)xmlNodeGetContent(node);
                        userDefined += (char*)name + ": " + value + " (Type: " + (vtype ? (char*)vtype : "string") + ")\n";
                        xmlFree(name);
                        xmlFree(vtype);
                    }
                    node = node->next;
                }
                properties["User-Defined Metadata"] = !userDefined.empty() ? userDefined : "None";

                xmlFreeDoc(doc);
            }
            delete[] buf;
            zip_fclose(metaFile);
        }
    }

    void printProperties() {
        for (const auto& p : properties) {
            std::cout << p.first << ": " << p.second << std::endl;
        }
    }

    void writeProperty(const std::string& key, const std::string& value) {
        properties[key] = value;
        // Full write requires updating XML and re-zipping
    }

    void save(const std::string& newFilepath) {
        // Placeholder: Implement by creating new zip, copying files, updating meta.xml
        std::cout << "Save not fully implemented." << std::endl;
    }

    void close() {
        if (zip) zip_close(zip);
        zip = nullptr;
    }
};

// Usage example:
// int main() {
//     OTTFile ott("example.ott");
//     if (ott.open()) {
//         ott.printProperties();
//         ott.writeProperty("Title", "New Title");
//         ott.save("new.ott");
//     }
//     return 0;
// }