diff --git a/.cursorrules b/.cursorrules index f904914..68f8d1e 100644 --- a/.cursorrules +++ b/.cursorrules @@ -4,7 +4,7 @@ OpenFCPXMLKit is a modern, fully modular Swift 6 framework for Final Cut Pro FCP Keep this file in sync with AGENT.md. Both should describe the same overview, architecture, test structure, and conventions. When you update one, update the other. -**Naming:** Use OpenFCPXMLKit naming exclusively in all code, documentation, comments, and agent files (`ServiceLogger`, `createService()`, `OFKXML*` types). Do not use legacy project names or identifiers from prior forks. Never use the terms "PBF" or "Production's Best Friend" in source code, code comments, symbol names, or CLI/log output; describe the reporting feature neutrally (e.g. "Excel report", "role inventory report", "workbook export"). Those terms may appear only in prose documentation (README, CHANGELOG, Manual, and these agent guides) — never in the codebase itself. +**Naming:** Use OpenFCPXMLKit naming exclusively in all code, documentation, comments, and agent files (`ServiceLogger`, `createService()`, `OFKXML*` types). Do not use legacy project names or identifiers from prior forks. Never use the terms "PBF" or "Production's Best Friend" in source code, code comments, symbol names, or CLI/log output; describe the reporting feature neutrally (e.g. "Excel report", "PDF report", "role inventory report", "workbook export"). Those terms may appear only in prose documentation (README, CHANGELOG, Manual, and these agent guides) — never in the codebase itself. --- @@ -40,7 +40,7 @@ OpenFCPXMLKit targets macOS 26+, iOS 26+, Xcode 26+, and Swift 6.3 with full con **Backward compatibility:** The entire codebase must remain backward compatible with FCPXML 1.5. Optional attributes and elements introduced in later versions (e.g. 1.11, 1.13) must be omitted or ignored when reading/writing or converting to 1.5; mark such features in code comments with the minimum FCPXML version (e.g. `FCPXML 1.13+`). -Current status: **933** tests listed in `swift test --list-tests` (**932** in `OpenFCPXMLKitTests`: 929 XCTest + 3 Swift Testing `@Test`; plus **1** optional `ExcelReportTest` integration); FCPXML versions 1.5–1.14 supported (DTDs included, full parsing, typed element-type coverage for all DTD elements via FCPXMLElementType); Final Cut Pro frame rates (23.976, 24, 25, 29.97, 30, 50, 59.94, 60); thread-safe and concurrency-compliant with comprehensive async/await support; no known security vulnerabilities. Version conversion automatically drops elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); DTD validation runs per version (validateDocumentAgainstDTD, validateDocumentAgainstDeclaredVersion) and after CLI convert. FCPXMLVersion.supportsBundleFormat is true for 1.10+ (.fcpxmld bundle); 1.5–1.9 support only single-file .fcpxml. FCPXML creation: create FCPXML documents from scratch with events, projects, resources, and clips via XMLDocumentManager, XMLDocument initializers, or FCPXMLService. Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment, clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata, timestamps (createdAt, modifiedAt). FCPXMLTimecode: custom timecode type (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, still image asset support. TimelineFormat enhancements: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). Typed adjustment models: Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation with full clip integration. Typed effect/filter models: VideoFilter, AudioFilter, VideoFilterMask with FilterParameter support and keyframe animation (auxValue support FCPXML 1.11+). Typed caption/title models: Caption and Title with TextStyle and TextStyleDefinition for full text formatting. SmartCollection models: SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14). Keyframe animation: KeyframeAnimation, Keyframe with interpolation types, FadeIn/FadeOut with fade types, integrated with FilterParameter. CMTime Codable extension: Direct CMTime encoding/decoding as FCPXML time strings. Collection organization: CollectionFolder and KeywordCollection models for organizing clips and media. Live Drawing (FCPXML 1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (FCPXML 1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye, Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Cross-platform XML abstraction: protocol layer (OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLFactory); Foundation backend on macOS; AEXML backend on iOS; OFKXMLDefaultFactory() for platform dispatch; FCPXMLStructuralValidator for cross-platform structural validation; FCPXMLDTDValidator platform-conditional (full DTD on macOS, structural fallback on iOS). Comprehensive test coverage: **933** tests across 58 FCPXML sample files including 360 video, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration, secondary storyline detection), keyword collections/folders, Photoshop integration, smart collections, and reporting column layout/exclusion/disabled-clip/workbook-formatting tests. Excel reporting: multi-sheet `.xlsx` workbooks via `FinalCutPro.FCPXML.buildReport(options:)` (ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit); sheets for Role Inventory (**Selected Roles Inventory** + per-role sheets with expanded column layout and dynamic metadata keys), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, **Summary** (project title header row, black role-duration data), and **Media Summary** (red missing-media paths); inventory and section-sheet cell formatting, role exclusions, global column exclusion (`ReportColumn` / `excludedColumns`), disabled-clip filtering (`excludeDisabledClips`), project-name / compound-clip-name filtering (`allReportTimelineSources()`; standalone compound-clip exports without ``), `ReportTimecodeFormat` / `--timecode-format`, inventory-first `ReportBuildPhase` progress callbacks. Extraction presets: Captions, Effects, FrameData, Markers, Roles, Titles. Experimental CLI (OpenFCPXMLKit-CLI): single binary with embedded DTDs; --check-version, --convert-version (stripping + DTD validation), --extension-type (fcpxmld | fcpxml; default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (new empty FCPXML project: --width, --height, --rate, --project-version, output-dir; DTD validation before write; FCP-style output with DOCTYPE, colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-markers, --report-keywords, --report-titles-generators, --report-transitions, --report-effects, --report-speed-change-effects, --report-summary, --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project); --log writes user-visible output for all commands to the log file; see Sources/OpenFCPXMLKitCLI/README.md. +Current status: **944** tests listed in `swift test --list-tests` (**942** in `OpenFCPXMLKitTests`: 939 XCTest + 3 Swift Testing `@Test`; plus **2** optional `ExcelReportTest` integration tests); FCPXML versions 1.5–1.14 supported (DTDs included, full parsing, typed element-type coverage for all DTD elements via FCPXMLElementType); Final Cut Pro frame rates (23.976, 24, 25, 29.97, 30, 50, 59.94, 60); thread-safe and concurrency-compliant with comprehensive async/await support; no known security vulnerabilities. Version conversion automatically drops elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); DTD validation runs per version (validateDocumentAgainstDTD, validateDocumentAgainstDeclaredVersion) and after CLI convert. FCPXMLVersion.supportsBundleFormat is true for 1.10+ (.fcpxmld bundle); 1.5–1.9 support only single-file .fcpxml. FCPXML creation: create FCPXML documents from scratch with events, projects, resources, and clips via XMLDocumentManager, XMLDocument initializers, or FCPXMLService. Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment, clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata, timestamps (createdAt, modifiedAt). FCPXMLTimecode: custom timecode type (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, still image asset support. TimelineFormat enhancements: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). Typed adjustment models: Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation with full clip integration. Typed effect/filter models: VideoFilter, AudioFilter, VideoFilterMask with FilterParameter support and keyframe animation (auxValue support FCPXML 1.11+). Typed caption/title models: Caption and Title with TextStyle and TextStyleDefinition for full text formatting. SmartCollection models: SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14). Keyframe animation: KeyframeAnimation, Keyframe with interpolation types, FadeIn/FadeOut with fade types, integrated with FilterParameter. CMTime Codable extension: Direct CMTime encoding/decoding as FCPXML time strings. Collection organization: CollectionFolder and KeywordCollection models for organizing clips and media. Live Drawing (FCPXML 1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (FCPXML 1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye, Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Cross-platform XML abstraction: protocol layer (OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLFactory); Foundation backend on macOS; AEXML backend on iOS; OFKXMLDefaultFactory() for platform dispatch; FCPXMLStructuralValidator for cross-platform structural validation; FCPXMLDTDValidator platform-conditional (full DTD on macOS, structural fallback on iOS). Comprehensive test coverage: **944** tests across 58 FCPXML sample files including 360 video, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration, secondary storyline detection), keyword collections/folders, Photoshop integration, smart collections, and reporting column layout/exclusion/disabled-clip/workbook/PDF formatting tests. Excel and PDF reporting: multi-sheet `.xlsx` workbooks via `FinalCutPro.FCPXML.buildReport(options:)` (ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit) and optional `.pdf` via `ReportPDFExport` (CoreGraphics; cover page, dynamic TOC, section pagination; same Report configuration as Excel); sheets for Role Inventory (**Selected Roles Inventory** + per-role sheets with expanded column layout and dynamic metadata keys), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, **Summary** (project title header row, black role-duration data), and **Media Summary** (red missing-media paths); inventory and section-sheet cell formatting, role exclusions, global column exclusion (`ReportColumn` / `excludedColumns`), disabled-clip filtering (`excludeDisabledClips`), project-name / compound-clip-name filtering (`allReportTimelineSources()`; standalone compound-clip exports without ``), `ReportTimecodeFormat` / `--timecode-format`, inventory-first `ReportBuildPhase` progress callbacks. Extraction presets: Captions, Effects, FrameData, Markers, Roles, Titles. Experimental CLI (OpenFCPXMLKit-CLI): single binary with embedded DTDs; --check-version, --convert-version (stripping + DTD validation), --extension-type (fcpxmld | fcpxml; default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (new empty FCPXML project: --width, --height, --rate, --project-version, output-dir; DTD validation before write; FCP-style output with DOCTYPE, colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-markers, --report-keywords, --report-titles-generators, --report-transitions, --report-effects, --report-speed-change-effects, --report-summary, --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project, --create-pdf); --log writes user-visible output for all commands to the log file; see Sources/OpenFCPXMLKitCLI/README.md. Xcode 26 dynamic linking compatibility: `swift-log` (`Logging`) is an explicit direct dependency in `Package.swift` to satisfy stricter transitive dylib linking rules when building OpenFCPXMLKit as a dynamic framework. @@ -52,7 +52,7 @@ The project was fully rewritten and refactored to achieve: - A protocol-oriented design: parsing, timecode conversion, XML manipulation, error handling, MIME type detection, asset validation, silence detection, asset duration measurement, and parallel file I/O are defined as protocols (e.g. FCPXMLParsing, TimecodeConversion, XMLDocumentOperations, ErrorHandling, MIMETypeDetection, AssetValidation, SilenceDetection, AssetDurationMeasurement, ParallelFileIO) with sync and async/await methods. - A single injection point for extension APIs that cannot take parameters: FCPXMLUtility.defaultForExtensions (concurrency-safe). No hidden concrete types in extensions; for custom services use the modular API with the using: parameter. -- Consistent source layout: Analysis, Classes, Delegates, Errors, Extensions (including +Modular and +Codable), Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model (with subfolders), Parsing, Extraction, Reporting (including Excel/ for XLKit workbook export; `ReportBuilder` resolves via `allReportTimelineSources()`), XML (Protocols: OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLDTDProtocol, OFKXMLFactory; Foundation/ and AEXML/ backends; OFKXMLDefaultFactory), and FCPXML DTDs. +- Consistent source layout: Analysis, Classes, Delegates, Errors, Extensions (including +Modular and +Codable), Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model (with subfolders), Parsing, Extraction, Reporting (including Excel/ for XLKit workbook export and PDF/ for CoreGraphics PDF export; `ReportBuilder` resolves via `allReportTimelineSources()`), XML (Protocols: OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLDTDProtocol, OFKXMLFactory; Foundation/ and AEXML/ backends; OFKXMLDefaultFactory), and FCPXML DTDs. - A structured test suite: shared resources, file tests per sample, logic/parsing tests, timeline/export/validation tests, API and edge-case tests, and performance tests, all documented in Tests/README.md. Foundation XML types (XMLDocument, XMLElement), protocol types (OFKXMLDocument, OFKXMLElement), and SwiftTimecode types are not Sendable. The codebase avoids Task-based concurrency for these types but provides async/await APIs that are concurrency-safe for Swift 6. If these dependencies become Sendable in the future, further parallelisation can be introduced. @@ -67,7 +67,7 @@ Foundation XML types (XMLDocument, XMLElement), protocol types (OFKXMLDocument, - ModularUtilities.validateDocument delegates to FCPXMLValidator for semantic validation (root element, resources, ref resolution). convertTimecodes uses the injected FCPXMLTimeStringConversion protocol rather than inline parsing. - Logging: ServiceLogger (ServiceLogLevel: trace, debug, info, notice, warning, error, critical); NoOpServiceLogger, PrintServiceLogger, FileServiceLogger. FCPXMLUtility and FCPXMLService use injected logger; CLI supports --log, --log-level, --quiet. When --log is set, all CLI commands (check-version, convert-version, validate, media-copy, create-project) write their user-visible messages to the log file. Extension types use #if canImport(Logging) as fallback without DI. - FCPXMLVersion (DTD validation, 1.5-1.14) and FinalCutPro.FCPXML.Version (parsing, 1.0-1.14) are bridged via .fcpxmlVersion, .dtdVersion, and init(from:) converters. -- Reporting: Excel report builders in `Reporting/` consume Extraction/Model; extend Model/Parsing and Extraction before adding report-only XML walks. Sheet formatting, column layout (`RoleInventoryColumnLayout`), global column exclusion (`ReportColumnExclusion`, including format-suffixed headers), disabled-clip filtering (`excludeDisabledClips`), `ReportTimecodeFormat` / format-aware headers, inventory-first `ReportBuildPhase.enabledPhases(for:)` progress, workbook cell colours (`FCPXMLReportWorkbookExporter`, `RoleRowColorContext`), and inclusion rules stay in Reporting. Summary and Media Summary are separate sheets. Timeline resolution via `allReportTimelineSources()` / `ReportTimelineSource` (projects + standalone compound-clip exports). See ARCHITECTURE.md §2.7. +- Reporting: Excel and PDF report builders in `Reporting/` consume Extraction/Model; extend Model/Parsing and Extraction before adding report-only XML walks. Sheet formatting, column layout (`RoleInventoryColumnLayout`), global column exclusion (`ReportColumnExclusion`, including format-suffixed headers), disabled-clip filtering (`excludeDisabledClips`), `ReportTimecodeFormat` / format-aware headers, inventory-first `ReportBuildPhase.enabledPhases(for:)` progress, shared row colours (`FCPXMLReportRowColorPolicy` for Excel and PDF), workbook cell colours (`FCPXMLReportWorkbookExporter`, `RoleRowColorContext`), PDF layout (`Reporting/PDF/` via `ReportPDFExport`), and inclusion rules stay in Reporting. Build `Report` once; export to Excel, PDF, or both with the same options. Summary and Media Summary are separate sheets. Timeline resolution via `allReportTimelineSources()` / `ReportTimelineSource` (projects + standalone compound-clip exports). See ARCHITECTURE.md §2.7. - Module-scoped errors: FCPXMLError for parsing, FCPXMLLoadError for file I/O (notAFile, readFailed), FCPXMLExportError/FCPXMLBundleExportError for export, FinalCutPro.FCPXML.ParseError (with LocalizedError). Parse failures from all layers surface as FCPXMLError.parsingFailed. FCPXMLElementError uses String element names for Sendable compliance. FCPXMLDocumentError uses camelCase cases (dtdResourceNotFound, dtdResourceUnreadable). - All code is Sendable where appropriate; `@unchecked Sendable` removed from delegates (internal-only, used synchronously). The project builds and tests with Swift 6 strict concurrency (-strict-concurrency=complete). CI runs a job that enforces this. - No known vulnerabilities in dependencies (including SwiftTimecode 3.1.2) as of July 2025. No unsafe pointers, dynamic code execution, or C APIs; concurrency is structured and type-safe. @@ -128,7 +128,7 @@ SwiftTimecode usage: use Timecode(.realTime(seconds: seconds), at: frameRate) in ## File Organisation -Source structure: layout is Analysis (EditPoint, CutDetectionResult), Classes (FinalCutPro, FCPXML core types including `allReportTimelineSources` / `ReportTimelineSource`, FCPXMLElementType, FCPXMLUtility, FCPXMLVersion, FCPXMLRoot, FCPXMLRootVersion), Delegates, Errors (FCPXMLError, FCPXMLParseError, TimelineError), Extensions (including +Modular and +Codable; FCPXML extensions operate on OFKXMLDocument/OFKXMLElement), Implementations (FCPXMLParser, TimecodeConverter, XMLDocumentManager, ErrorHandler, CutDetector, FCPXMLVersionConverter, MediaExtractor, MIMETypeDetector, AssetValidator, SilenceDetector, AssetDurationMeasurer, ParallelFileIOExecutor), Protocols (FCPXMLParsing, TimecodeConversion, XMLDocumentOperations, ErrorHandling, CutDetection, FCPXMLVersionConverting, MediaExtraction, MIMETypeDetection, AssetValidation, SilenceDetection, AssetDurationMeasurement, ParallelFileIO), Services, Utilities (ModularUtilities, FCPXMLTimeUtilities, SequencePlusAnySequence, XMLElementAncestorWalking, XMLElementSequenceAttributes), Annotations (creation-oriented value types; for parsing models see Model/), Export (FCPXMLExporter, FCPXMLBundleExporter, FCPXMLExportAsset), Timeline (Timeline with manipulation methods, TimelineClip with asset validation methods, TimelineFormat with presets and computed properties), Timing (FCPXMLTimecode), Validation (FCPXMLValidator, FCPXMLDTDValidator, FCPXMLStructuralValidator, ValidationResult, ValidationError/Warning), FileIO (FCPXMLFileLoader), Media (MediaReference, MediaExtractionResult, MediaCopyResult), Logging (ServiceLogger, ServiceLogLevel, NoOpServiceLogger, PrintServiceLogger, FileServiceLogger), Format (ColorSpace), Model (element models with subfolders: Adjustments (CropAdjustment, TransformAdjustment, BlendAdjustment, StabilizationAdjustment, VolumeAdjustment, LoudnessAdjustment, NoiseReductionAdjustment, HumReductionAdjustment, EqualizationAdjustment, MatchEqualizationAdjustment, Transform360Adjustment), Animations (KeyframeAnimation, Keyframe, FadeIn, FadeOut, FadeType), Attributes, Clips including Clip+Adjustments, Title+Typed, CommonElements including Text, TextStyle, TextStyleDefinition, ElementTypes, Filters (VideoFilter, AudioFilter, VideoFilterMask, FilterParameter), Occlusion, Protocols, Resources, Roles, Structure including CollectionFolder, KeywordCollection), Parsing (XML parsing extensions), Extraction (extraction logic with Context/, Effects/, and Presets/ subfolders; presets: Captions, Effects, FrameData, Markers, Roles, Titles), Reporting (Excel workbook reports: Report/ReportOptions/ReportBuilder/ReportTimecodeFormat/ReportBuildProgress, Builders/, Sections/, Rows/, Support/ including RoleInventoryColumnLayout, ReportColumnExclusion, ReportFormatting; Excel/ for XLKit workbook export; consumes Extraction, owns presentation only — see ARCHITECTURE.md §2.7), XML (Protocols: OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLDTDProtocol, OFKXMLFactory; Foundation/ and AEXML/ backends; OFKXMLDefaultFactory), FCPXML DTDs. Group related functionality in extensions; keep files focused on single responsibilities; use clear file naming conventions; organise imports logically; maintain the existing directory structure. +Source structure: layout is Analysis (EditPoint, CutDetectionResult), Classes (FinalCutPro, FCPXML core types including `allReportTimelineSources` / `ReportTimelineSource`, FCPXMLElementType, FCPXMLUtility, FCPXMLVersion, FCPXMLRoot, FCPXMLRootVersion), Delegates, Errors (FCPXMLError, FCPXMLParseError, TimelineError), Extensions (including +Modular and +Codable; FCPXML extensions operate on OFKXMLDocument/OFKXMLElement), Implementations (FCPXMLParser, TimecodeConverter, XMLDocumentManager, ErrorHandler, CutDetector, FCPXMLVersionConverter, MediaExtractor, MIMETypeDetector, AssetValidator, SilenceDetector, AssetDurationMeasurer, ParallelFileIOExecutor), Protocols (FCPXMLParsing, TimecodeConversion, XMLDocumentOperations, ErrorHandling, CutDetection, FCPXMLVersionConverting, MediaExtraction, MIMETypeDetection, AssetValidation, SilenceDetection, AssetDurationMeasurement, ParallelFileIO), Services, Utilities (ModularUtilities, FCPXMLTimeUtilities, SequencePlusAnySequence, XMLElementAncestorWalking, XMLElementSequenceAttributes), Annotations (creation-oriented value types; for parsing models see Model/), Export (FCPXMLExporter, FCPXMLBundleExporter, FCPXMLExportAsset), Timeline (Timeline with manipulation methods, TimelineClip with asset validation methods, TimelineFormat with presets and computed properties), Timing (FCPXMLTimecode), Validation (FCPXMLValidator, FCPXMLDTDValidator, FCPXMLStructuralValidator, ValidationResult, ValidationError/Warning), FileIO (FCPXMLFileLoader), Media (MediaReference, MediaExtractionResult, MediaCopyResult), Logging (ServiceLogger, ServiceLogLevel, NoOpServiceLogger, PrintServiceLogger, FileServiceLogger), Format (ColorSpace), Model (element models with subfolders: Adjustments (CropAdjustment, TransformAdjustment, BlendAdjustment, StabilizationAdjustment, VolumeAdjustment, LoudnessAdjustment, NoiseReductionAdjustment, HumReductionAdjustment, EqualizationAdjustment, MatchEqualizationAdjustment, Transform360Adjustment), Animations (KeyframeAnimation, Keyframe, FadeIn, FadeOut, FadeType), Attributes, Clips including Clip+Adjustments, Title+Typed, CommonElements including Text, TextStyle, TextStyleDefinition, ElementTypes, Filters (VideoFilter, AudioFilter, VideoFilterMask, FilterParameter), Occlusion, Protocols, Resources, Roles, Structure including CollectionFolder, KeywordCollection), Parsing (XML parsing extensions), Extraction (extraction logic with Context/, Effects/, and Presets/ subfolders; presets: Captions, Effects, FrameData, Markers, Roles, Titles), Reporting (Excel and PDF report export: Report/ReportOptions/ReportBuilder/ReportTimecodeFormat/ReportBuildProgress, Builders/, Sections/, Rows/, Support/ including RoleInventoryColumnLayout, ReportColumnExclusion, ReportFormatting, FCPXMLReportRowColorPolicy; Excel/ for XLKit workbook export; PDF/ for CoreGraphics PDF export via ReportPDFExport; consumes Extraction, owns presentation only — see ARCHITECTURE.md §2.7), XML (Protocols: OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLDTDProtocol, OFKXMLFactory; Foundation/ and AEXML/ backends; OFKXMLDefaultFactory), FCPXML DTDs. Group related functionality in extensions; keep files focused on single responsibilities; use clear file naming conventions; organise imports logically; maintain the existing directory structure. --- @@ -138,13 +138,13 @@ Tests live under Tests/. Full description is in Tests/README.md. Summary: - Tests/README.md: test categories, how to run (Swift PM, Xcode, Linux), sample files, frame rates, FCPXML versions. Keep it current when adding or changing tests. -- Tests/ExcelReportTest/: optional integration target (1 test; writes OFK-Default.xlsx and OFK-Full.xlsx from a local fixture; XCTSkip when fixture missing). See Tests/ExcelReportTest/README.md. +- Tests/ExcelReportTest/: optional integration target (2 tests; writes `Output/OFK-Default.xlsx`, `Output/OFK-Full.xlsx`, and `Output/OFK-Default.pdf` from a local fixture; XCTSkip when fixture missing). See Tests/ExcelReportTest/README.md. - Tests/FCPXML Samples/FCPXML/: sample .fcpxml files (58 samples). File tests and logic tests load these via shared utilities; tests that require a missing sample use XCTSkip. -- Tests/OpenFCPXMLKitTests/: FCPXMLTestResources.swift (packageRoot, fcpxmlSamplesDirectory, urlForFCPXMLSample, FCPXMLSampleName); FCPXMLTestUtilities.swift (loadFCPXMLSampleData, loadFCPXMLSample, fcpxmlFrameRateSampleNames, allFCPXMLSampleNames; XCTSkip when sample missing); FCPXMLReportingReportFixture.swift and FCPXMLReportingReportTestSupport.swift (optional reporting integration fixtures/assertions). OpenFCPXMLKitTests.swift: main test class with injected dependencies; MARK sections group tests. FileTests/: one test class per sample or category (e.g. FCPXMLFileTest_24, FCPXMLFileTest_Structure, FCPXMLFileTest_AllSamples, FCPXMLFileTest_FrameRates, FCPXMLFileTest_360Video, FCPXMLFileTest_AuditionSample, FCPXMLFileTest_ImageSample, FCPXMLFileTest_Multicam, FCPXMLFileTest_Photoshop, FCPXMLFileTest_SmartCollection). LogicAndParsing/: FCPXMLRootVersionTests, FCPXMLStructureTests, FCPXMLFormatAssetTests. Every test-case class is FCPXML-prefixed except the module-named umbrella OpenFCPXMLKitTests. FCPXMLTimelineManipulationTests: ripple insert, auto lane assignment, clip queries, lane range, timestamps, metadata, secondary storylines, audio keyframes; injectable "now" for timestamp tests via lock-based NowBox (no DispatchSemaphore); do-catch for insertClipAutoLane/insertingClipAutoLane to verify success. FCPXMLTimecodeTests: initialization, arithmetic, comparison, CMTime conversion, frame alignment, hashing, codable. FCPXMLMIMETypeDetectionTests: sync and async detection for various file types. FCPXMLAssetValidationTests: asset existence, lane compatibility, TimelineClip integration. FCPXMLSilenceDetectionTests: silence detection at start/end of audio files. FCPXMLAssetDurationMeasurementTests: duration measurement for audio/video/images. FCPXMLParallelFileIOTests: concurrent read/write operations. FCPXMLAudioEnhancementTests: NoiseReduction, HumReduction, Equalization, MatchEqualization, Clip integration. FCPXMLTransform360Tests: coordinate types, spherical/cartesian, clip integration. FCPXMLCaptionTitleTests: TextStyle, TextStyleDefinition, Caption/Title integration, CaptionSample file test. FCPXMLKeyframeAnimationTests: FadeIn, FadeOut, Keyframe, KeyframeAnimation, FilterParameter integration. FCPXMLAudioKeyframeTests: audio keyframes in adjust-volume (param name="amount" with keyframeAnimation); parsing from FCPXML samples; decibel values (-3dB, -37dB); time values (FCPXML fractional format); fadeIn/fadeOut integration; multiple keyframes in sequence; secondary storyline and nested clip detection; TimelineWithSecondaryStorylineWithAudioKeyframes, TimelineSample file tests. FCPXMLCMTimeCodableTests: CMTime encoding/decoding as FCPXML time strings. FCPXMLCollectionTests: CollectionFolder, KeywordCollection, nested structures. FCPXMLSmartCollectionTests: SmartCollection models, match rules (MatchUsage, MatchRepresentation, MatchMarkers, MatchAnalysisType), round-trip, version stripping. FCPXMLAdjustmentTests: typed adjustment models and clip integration. FCPXMLFilterTests: VideoFilter, AudioFilter, VideoFilterMask, FilterParameter. FCPXMLImportOptionsTests: import options and library location parsing. FCPXMLCodableTests: Codable round-trip for model types. FCPXMLMediaExtractionTests: media reference extraction and copy (CLI --media-copy flow). FCPXMLDTDValidatorTests: per-version DTD validation. FCPXMLStructuralValidatorTests: cross-platform structural validation. FCPXMLAEXMLSerializationParityTests: AEXML vs Foundation serialization parity. FCPXMLTimelineExportValidationTests: timeline, exporters (empty timeline creation and project-creation export at different sizes and frame rates; includeDefaultSmartCollections and DTD validation), validators, file loader. FCPXMLAPIAndEdgeCaseTests: async load API, ServiceLogger injection, edge cases, Live Drawing (1.11+), HiddenClipMarker (1.13+). FCPXMLCutDetectionTests: edit points, transitions, gaps, CutSample file test. FCPXMLPerformanceTests: parameterised and basic performance tests. Reporting/extraction tests are all FCPXML-prefixed (FCPXMLCompoundClipReportTests, FCPXMLRoleInventoryReportTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests). +- Tests/OpenFCPXMLKitTests/: FCPXMLTestResources.swift (packageRoot, fcpxmlSamplesDirectory, urlForFCPXMLSample, FCPXMLSampleName); FCPXMLTestUtilities.swift (loadFCPXMLSampleData, loadFCPXMLSample, fcpxmlFrameRateSampleNames, allFCPXMLSampleNames; XCTSkip when sample missing); FCPXMLReportingReportFixture.swift and FCPXMLReportingReportTestSupport.swift (optional reporting integration fixtures/assertions). OpenFCPXMLKitTests.swift: main test class with injected dependencies; MARK sections group tests. FileTests/: one test class per sample or category (e.g. FCPXMLFileTest_24, FCPXMLFileTest_Structure, FCPXMLFileTest_AllSamples, FCPXMLFileTest_FrameRates, FCPXMLFileTest_360Video, FCPXMLFileTest_AuditionSample, FCPXMLFileTest_ImageSample, FCPXMLFileTest_Multicam, FCPXMLFileTest_Photoshop, FCPXMLFileTest_SmartCollection). LogicAndParsing/: FCPXMLRootVersionTests, FCPXMLStructureTests, FCPXMLFormatAssetTests. Every test-case class is FCPXML-prefixed except the module-named umbrella OpenFCPXMLKitTests. FCPXMLTimelineManipulationTests: ripple insert, auto lane assignment, clip queries, lane range, timestamps, metadata, secondary storylines, audio keyframes; injectable "now" for timestamp tests via lock-based NowBox (no DispatchSemaphore); do-catch for insertClipAutoLane/insertingClipAutoLane to verify success. FCPXMLTimecodeTests: initialization, arithmetic, comparison, CMTime conversion, frame alignment, hashing, codable. FCPXMLMIMETypeDetectionTests: sync and async detection for various file types. FCPXMLAssetValidationTests: asset existence, lane compatibility, TimelineClip integration. FCPXMLSilenceDetectionTests: silence detection at start/end of audio files. FCPXMLAssetDurationMeasurementTests: duration measurement for audio/video/images. FCPXMLParallelFileIOTests: concurrent read/write operations. FCPXMLAudioEnhancementTests: NoiseReduction, HumReduction, Equalization, MatchEqualization, Clip integration. FCPXMLTransform360Tests: coordinate types, spherical/cartesian, clip integration. FCPXMLCaptionTitleTests: TextStyle, TextStyleDefinition, Caption/Title integration, CaptionSample file test. FCPXMLKeyframeAnimationTests: FadeIn, FadeOut, Keyframe, KeyframeAnimation, FilterParameter integration. FCPXMLAudioKeyframeTests: audio keyframes in adjust-volume (param name="amount" with keyframeAnimation); parsing from FCPXML samples; decibel values (-3dB, -37dB); time values (FCPXML fractional format); fadeIn/fadeOut integration; multiple keyframes in sequence; secondary storyline and nested clip detection; TimelineWithSecondaryStorylineWithAudioKeyframes, TimelineSample file tests. FCPXMLCMTimeCodableTests: CMTime encoding/decoding as FCPXML time strings. FCPXMLCollectionTests: CollectionFolder, KeywordCollection, nested structures. FCPXMLSmartCollectionTests: SmartCollection models, match rules (MatchUsage, MatchRepresentation, MatchMarkers, MatchAnalysisType), round-trip, version stripping. FCPXMLAdjustmentTests: typed adjustment models and clip integration. FCPXMLFilterTests: VideoFilter, AudioFilter, VideoFilterMask, FilterParameter. FCPXMLImportOptionsTests: import options and library location parsing. FCPXMLCodableTests: Codable round-trip for model types. FCPXMLMediaExtractionTests: media reference extraction and copy (CLI --media-copy flow). FCPXMLDTDValidatorTests: per-version DTD validation. FCPXMLStructuralValidatorTests: cross-platform structural validation. FCPXMLAEXMLSerializationParityTests: AEXML vs Foundation serialization parity. FCPXMLTimelineExportValidationTests: timeline, exporters (empty timeline creation and project-creation export at different sizes and frame rates; includeDefaultSmartCollections and DTD validation), validators, file loader. FCPXMLAPIAndEdgeCaseTests: async load API, ServiceLogger injection, edge cases, Live Drawing (1.11+), HiddenClipMarker (1.13+). FCPXMLCutDetectionTests: edit points, transitions, gaps, CutSample file test. FCPXMLPerformanceTests: parameterised and basic performance tests. Reporting/extraction tests are all FCPXML-prefixed (FCPXMLCompoundClipReportTests, FCPXMLRoleInventoryReportTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportPDFExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests). -Use descriptive test method names; group related tests logically; include setup and teardown; use meaningful assertions. Current total: **933** tests listed in `swift test --list-tests` (**932** in `OpenFCPXMLKitTests` + **1** optional `ExcelReportTest`) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, version conversion stripping, per-version DTD validation, extract-then-copy (CLI --media-copy flow), synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests refactor (lock-based NowBox, do-catch for throwing APIs), Excel reporting (Selected Roles Inventory column layout, Summary/Media Summary split, ReportTimecodeFormat / format-aware headers, inventory-first ReportBuildPhase progress, global column exclusion, disabled-clip filtering, workbook cell formatting via FCPXMLReportWorkbookExporter), 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. +Use descriptive test method names; group related tests logically; include setup and teardown; use meaningful assertions. Current total: **944** tests listed in `swift test --list-tests` (**942** in `OpenFCPXMLKitTests` + **2** optional `ExcelReportTest`) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, version conversion stripping, per-version DTD validation, extract-then-copy (CLI --media-copy flow), synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests refactor (lock-based NowBox, do-catch for throwing APIs), Excel reporting (Selected Roles Inventory column layout, Summary/Media Summary split, ReportTimecodeFormat / format-aware headers, inventory-first ReportBuildPhase progress, global column exclusion, disabled-clip filtering, workbook cell formatting via FCPXMLReportWorkbookExporter), 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. --- @@ -160,7 +160,7 @@ SwiftTimecode integration: use SwiftTimecode for all timecode operations; suppor ## Testing Requirements -Test coverage: unit tests for all public APIs; integration tests for complex workflows; performance tests for time-critical operations; concurrency tests for async operations; test all supported frame rates (Final Cut Pro compatible). Current: **933** tests listed in `swift test --list-tests` (including AEXML parity, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLReportExcelExportTests workbook cell formatting, optional ExcelReportTest integration) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, version conversion, DTD validation, extract-then-copy flow, synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests (lock-based NowBox, do-catch), Excel reporting enhancements, 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. +Test coverage: unit tests for all public APIs; integration tests for complex workflows; performance tests for time-critical operations; concurrency tests for async operations; test all supported frame rates (Final Cut Pro compatible). Current: **944** tests listed in `swift test --list-tests` (including AEXML parity, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLReportExcelExportTests workbook cell formatting, FCPXMLReportPDFExportTests PDF export, optional ExcelReportTest integration) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, version conversion, DTD validation, extract-then-copy flow, synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests (lock-based NowBox, do-catch), Excel reporting enhancements, PDF report export, 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. Test data: use realistic FCPXML samples; include edge cases and error conditions; test all supported frame rates; validate against actual Final Cut Pro output where applicable. @@ -219,7 +219,7 @@ Build requirements: Swift build successful; all tests passing; no warnings or er ## Project-Specific Rules -FCPXML handling: support FCPXML versions 1.5 through 1.14 (DTDs included; full parsing; typed element-type coverage via FCPXMLElementType for all DTD elements); validate against DTD schemas (FCPXMLDTDValidator, FCPXMLService.validateDocumentAgainstDTD/validateDocumentAgainstDeclaredVersion); version conversion (FCPXMLVersionConverter) sets root version and automatically strips elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); FCPXMLVersion.supportsBundleFormat is true for 1.10+ (save as .fcpxmld bundle); 1.5–1.9 support only .fcpxml; handle all supported frame rates (Final Cut Pro compatible); implement proper XML formatting. Media extraction: extract media references (asset media-rep src, locator url) and copy referenced file URLs to a destination directory (MediaExtraction protocol, MediaExtractor). Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment (finds available lanes), clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata on timeline and clips; timestamps (createdAt, modifiedAt) updated on all mutating operations. TimelineFormat: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). FCPXMLTimecode: custom timecode type wrapping Fraction (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection: MIMETypeDetection protocol and MIMETypeDetector implementation (UTType, AVFoundation, file extension fallback). Asset validation: AssetValidation protocol and AssetValidator implementation (existence check, MIME type compatibility, lane rules: negative = audio only, non-negative = video/image/audio); still image asset support (duration=0s). Silence detection: SilenceDetection protocol and SilenceDetector implementation (configurable threshold and minimum duration). Asset duration measurement: AssetDurationMeasurement protocol and AssetDurationMeasurer implementation (AVFoundation-based for audio/video/images). Parallel file I/O: ParallelFileIO protocol and ParallelFileIOExecutor implementation (concurrent read/write operations). SmartCollection: SmartCollection model with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14); library and event integration. Live Drawing (1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye (left|right), Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Excel reporting: multi-sheet `.xlsx` workbooks (FinalCutPro.FCPXML.buildReport(options:), ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit) with Role Inventory (**Selected Roles Inventory** + per-role sheets), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary (project metrics and role durations), and Media Summary (missing media paths); role exclusions, global column exclusion (`excludedColumns` / `ReportColumn`), disabled-clip filtering (`excludeDisabledClips`), `ReportTimecodeFormat` / `--timecode-format` (SMPTE frames with DF/NDF, Frames, Feet+Frames, HH:MM:SS; format-aware headers; numeric sort for Frames/Feet+Frames), inventory-first `ReportBuildPhase.enabledPhases(for:)` progress, and project-name filtering; builders consume Extraction/Model and own presentation only (see ARCHITECTURE.md §2.7). Experimental CLI: OpenFCPXMLKit-CLI single binary (embedded DTDs) with --check-version, --convert-version (stripping + DTD validation), --extension-type fcpxml|fcpxmld (default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (width, height, rate, project-version, output-dir; mandatory DTD validation; FCP-style DOCTYPE, format colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-summary and --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project), and LOG options (--log, --log-level, --quiet; --log records user-visible output for all commands); see Sources/OpenFCPXMLKitCLI/README.md. +FCPXML handling: support FCPXML versions 1.5 through 1.14 (DTDs included; full parsing; typed element-type coverage via FCPXMLElementType for all DTD elements); validate against DTD schemas (FCPXMLDTDValidator, FCPXMLService.validateDocumentAgainstDTD/validateDocumentAgainstDeclaredVersion); version conversion (FCPXMLVersionConverter) sets root version and automatically strips elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); FCPXMLVersion.supportsBundleFormat is true for 1.10+ (save as .fcpxmld bundle); 1.5–1.9 support only .fcpxml; handle all supported frame rates (Final Cut Pro compatible); implement proper XML formatting. Media extraction: extract media references (asset media-rep src, locator url) and copy referenced file URLs to a destination directory (MediaExtraction protocol, MediaExtractor). Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment (finds available lanes), clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata on timeline and clips; timestamps (createdAt, modifiedAt) updated on all mutating operations. TimelineFormat: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). FCPXMLTimecode: custom timecode type wrapping Fraction (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection: MIMETypeDetection protocol and MIMETypeDetector implementation (UTType, AVFoundation, file extension fallback). Asset validation: AssetValidation protocol and AssetValidator implementation (existence check, MIME type compatibility, lane rules: negative = audio only, non-negative = video/image/audio); still image asset support (duration=0s). Silence detection: SilenceDetection protocol and SilenceDetector implementation (configurable threshold and minimum duration). Asset duration measurement: AssetDurationMeasurement protocol and AssetDurationMeasurer implementation (AVFoundation-based for audio/video/images). Parallel file I/O: ParallelFileIO protocol and ParallelFileIOExecutor implementation (concurrent read/write operations). SmartCollection: SmartCollection model with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14); library and event integration. Live Drawing (1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye (left|right), Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Excel and PDF reporting: multi-sheet `.xlsx` workbooks (FinalCutPro.FCPXML.buildReport(options:), ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit) with Role Inventory (**Selected Roles Inventory** + per-role sheets), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary (project metrics and role durations), and Media Summary (missing media paths); role exclusions, global column exclusion (`excludedColumns` / `ReportColumn`), disabled-clip filtering (`excludeDisabledClips`), `ReportTimecodeFormat` / `--timecode-format` (SMPTE frames with DF/NDF, Frames, Feet+Frames, HH:MM:SS; format-aware headers; numeric sort for Frames/Feet+Frames), inventory-first `ReportBuildPhase.enabledPhases(for:)` progress, and project-name filtering; builders consume Extraction/Model and own presentation only (see ARCHITECTURE.md §2.7). Experimental CLI: OpenFCPXMLKit-CLI single binary (embedded DTDs) with --check-version, --convert-version (stripping + DTD validation), --extension-type fcpxml|fcpxmld (default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (width, height, rate, project-version, output-dir; mandatory DTD validation; FCP-style DOCTYPE, format colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-summary and --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project), and LOG options (--log, --log-level, --quiet; --log records user-visible output for all commands); see Sources/OpenFCPXMLKitCLI/README.md. Timecode operations: use SwiftTimecode for all timecode operations; support all FCPXML frame rates (Final Cut Pro compatible); implement proper frame rate conversions; handle drop frame and non-drop frame timecode. FCPXMLTimecode provides a custom timecode type for FCPXML-specific operations (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). @@ -253,7 +253,7 @@ Code review process: review for Swift 6.3 compliance; check concurrency implemen ## Documentation Sync -Keep this file in sync with AGENT.md. Both must reflect: changelog styling (CHANGELOG.md: Keep a Changelog format, version links to release tags, ✨ New Features / 🔧 Improvements / 🐛 Bug Fixes); project overview and codebase rewrite/refactor; architecture and single injection point (FCPXMLUtility.defaultForExtensions); source layout (Analysis, Classes, Delegates, Errors, Extensions including +Modular and +Codable, Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model including Adjustments, Animations, Filters, Clips with +Adjustments and +Typed, CommonElements with TextStyle/TextStyleDefinition, Structure with CollectionFolder/KeywordCollection/SmartCollection, Parsing, Extraction, Reporting including Excel/, XML with Protocols/Foundation/AEXML/OFKXMLDefaultFactory, FCPXML DTDs; reporting vs core layers in ARCHITECTURE.md §2.7); test structure (Tests/ layout, FCPXMLTestResources, FCPXMLTestUtilities, FileTests/ including FCPXMLFileTest_360Video, FCPXMLFileTest_AuditionSample, FCPXMLFileTest_ImageSample, FCPXMLFileTest_Multicam, FCPXMLFileTest_Photoshop, FCPXMLFileTest_SmartCollection, LogicAndParsing/ including FCPXMLFormatAssetTests, FCPXMLCutDetectionTests, FCPXMLVersionConversionTests, FCPXMLMediaExtractionTests, FCPXMLTimelineManipulationTests, FCPXMLTimecodeTests, FCPXMLMIMETypeDetectionTests, FCPXMLAssetValidationTests, FCPXMLSilenceDetectionTests, FCPXMLAssetDurationMeasurementTests, FCPXMLParallelFileIOTests, FCPXMLAudioEnhancementTests, FCPXMLTransform360Tests, FCPXMLCaptionTitleTests, FCPXMLKeyframeAnimationTests, FCPXMLAudioKeyframeTests, FCPXMLCMTimeCodableTests, FCPXMLCollectionTests, FCPXMLSmartCollectionTests, FCPXMLAdjustmentTests, FCPXMLFilterTests, FCPXMLImportOptionsTests, FCPXMLCodableTests, FCPXMLAEXMLSerializationParityTests, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, OpenFCPXMLKitTests.swift, FCPXMLTimelineExportValidationTests, FCPXMLAPIAndEdgeCaseTests, FCPXMLPerformanceTests, and FCPXML-prefixed reporting/extraction tests (FCPXMLRoleInventoryReportTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests); every test-case class is FCPXML-prefixed except the module-named umbrella OpenFCPXMLKitTests; empty timeline creation and project-creation export at different sizes and frame rates in FCPXMLTimelineExportValidationTests (clip-level metadata export, XML declaration standalone="no")). Cross-platform XML (OFKXML*, FCPXMLStructuralValidator, iOS). FCPXML 1.5–1.14 and FCPXMLElementType; FCPXMLVersion.supportsBundleFormat (1.10+); version conversion with element stripping and per-version DTD validation; FCPXML creation from scratch; timeline manipulation (ripple insert, auto lane assignment, clip queries, lane range, secondary storylines); timeline metadata (markers, chapter markers, keywords, ratings, timestamps); FCPXMLTimecode custom type; MIME type detection; asset validation (including still images); silence detection; asset duration measurement; parallel file I/O; TimelineFormat enhancements; typed adjustment models (Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation); typed effect/filter models (VideoFilter, AudioFilter, VideoFilterMask, FilterParameter with keyframe animation and auxValue 1.11+); typed caption/title models (Caption, Title with TextStyle, TextStyleDefinition); smart collections (SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type); keyframe animation (KeyframeAnimation, Keyframe, FadeIn, FadeOut); audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration); CMTime Codable extension; collection organization (CollectionFolder, KeywordCollection); Live Drawing (1.11+); HiddenClipMarker (1.13+); Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps); experimental CLI (OpenFCPXMLKit-CLI, single binary, embedded DTDs, --check-version, --convert-version, --extension-type fcpxml|fcpxmld, --validate, --media-copy, --create-project with DTD validation and FCP-style output, --report Excel report with --report-full/per-section flags/--report-summary/--report-media-summary/--exclude-role/--exclude-column/--exclude-disabled-clips/--timecode-format/--report-project, --log/--log-level/--quiet with log file capturing all command output); Excel reporting subsystem (`allReportTimelineSources` / compound-clip timelines) (Reporting/ builders, Sections/Rows, Support including RoleInventoryColumnLayout, ReportColumnExclusion, ReportFormatting, ReportTimecodeFormat, ReportBuildProgress; Excel/ via XLKit; buildReport/ReportExcelExport; Summary and Media Summary sheets; excludeDisabledClips/excludedColumns/timecodeFormat; inventory-first enabledPhases); extraction presets (Captions, Effects, FrameData, Markers, Roles, Titles); Tests/ExcelReportTest optional integration; Final Cut Pro frame rates; Swift 6 concurrency (Sendable, async/await, CI strict-concurrency job); Xcode 26 dynamic framework linking compatibility via explicit `swift-log` (`Logging`) dependency in `Package.swift`. When updating either file, update both and keep terminology and examples consistent. +Keep this file in sync with AGENT.md. Both must reflect: changelog styling (CHANGELOG.md: Keep a Changelog format, version links to release tags, ✨ New Features / 🔧 Improvements / 🐛 Bug Fixes); project overview and codebase rewrite/refactor; architecture and single injection point (FCPXMLUtility.defaultForExtensions); source layout (Analysis, Classes, Delegates, Errors, Extensions including +Modular and +Codable, Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model including Adjustments, Animations, Filters, Clips with +Adjustments and +Typed, CommonElements with TextStyle/TextStyleDefinition, Structure with CollectionFolder/KeywordCollection/SmartCollection, Parsing, Extraction, Reporting including Excel/ and PDF/, XML with Protocols/Foundation/AEXML/OFKXMLDefaultFactory, FCPXML DTDs; reporting vs core layers in ARCHITECTURE.md §2.7); test structure (Tests/ layout, FCPXMLTestResources, FCPXMLTestUtilities, FileTests/ including FCPXMLFileTest_360Video, FCPXMLFileTest_AuditionSample, FCPXMLFileTest_ImageSample, FCPXMLFileTest_Multicam, FCPXMLFileTest_Photoshop, FCPXMLFileTest_SmartCollection, LogicAndParsing/ including FCPXMLFormatAssetTests, FCPXMLCutDetectionTests, FCPXMLVersionConversionTests, FCPXMLMediaExtractionTests, FCPXMLTimelineManipulationTests, FCPXMLTimecodeTests, FCPXMLMIMETypeDetectionTests, FCPXMLAssetValidationTests, FCPXMLSilenceDetectionTests, FCPXMLAssetDurationMeasurementTests, FCPXMLParallelFileIOTests, FCPXMLAudioEnhancementTests, FCPXMLTransform360Tests, FCPXMLCaptionTitleTests, FCPXMLKeyframeAnimationTests, FCPXMLAudioKeyframeTests, FCPXMLCMTimeCodableTests, FCPXMLCollectionTests, FCPXMLSmartCollectionTests, FCPXMLAdjustmentTests, FCPXMLFilterTests, FCPXMLImportOptionsTests, FCPXMLCodableTests, FCPXMLAEXMLSerializationParityTests, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, OpenFCPXMLKitTests.swift, FCPXMLTimelineExportValidationTests, FCPXMLAPIAndEdgeCaseTests, FCPXMLPerformanceTests, and FCPXML-prefixed reporting/extraction tests (FCPXMLRoleInventoryReportTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportPDFExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests); every test-case class is FCPXML-prefixed except the module-named umbrella OpenFCPXMLKitTests; empty timeline creation and project-creation export at different sizes and frame rates in FCPXMLTimelineExportValidationTests (clip-level metadata export, XML declaration standalone="no")). Cross-platform XML (OFKXML*, FCPXMLStructuralValidator, iOS). FCPXML 1.5–1.14 and FCPXMLElementType; FCPXMLVersion.supportsBundleFormat (1.10+); version conversion with element stripping and per-version DTD validation; FCPXML creation from scratch; timeline manipulation (ripple insert, auto lane assignment, clip queries, lane range, secondary storylines); timeline metadata (markers, chapter markers, keywords, ratings, timestamps); FCPXMLTimecode custom type; MIME type detection; asset validation (including still images); silence detection; asset duration measurement; parallel file I/O; TimelineFormat enhancements; typed adjustment models (Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation); typed effect/filter models (VideoFilter, AudioFilter, VideoFilterMask, FilterParameter with keyframe animation and auxValue 1.11+); typed caption/title models (Caption, Title with TextStyle, TextStyleDefinition); smart collections (SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type); keyframe animation (KeyframeAnimation, Keyframe, FadeIn, FadeOut); audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration); CMTime Codable extension; collection organization (CollectionFolder, KeywordCollection); Live Drawing (1.11+); HiddenClipMarker (1.13+); Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps); experimental CLI (OpenFCPXMLKit-CLI, single binary, embedded DTDs, --check-version, --convert-version, --extension-type fcpxml|fcpxmld, --validate, --media-copy, --create-project with DTD validation and FCP-style output, --report Excel/PDF report with --report-full/per-section flags/--report-summary/--report-media-summary/--exclude-role/--exclude-column/--exclude-disabled-clips/--timecode-format/--report-project/--create-pdf, --log/--log-level/--quiet with log file capturing all command output); Excel and PDF reporting subsystem (`allReportTimelineSources` / compound-clip timelines) (Reporting/ builders, Sections/Rows, Support including RoleInventoryColumnLayout, ReportColumnExclusion, ReportFormatting, ReportTimecodeFormat, ReportBuildProgress; Excel/ via XLKit; buildReport/ReportExcelExport/ReportPDFExport; Summary and Media Summary sheets; excludeDisabledClips/excludedColumns/timecodeFormat; inventory-first enabledPhases); extraction presets (Captions, Effects, FrameData, Markers, Roles, Titles); Tests/ExcelReportTest optional integration; Final Cut Pro frame rates; Swift 6 concurrency (Sendable, async/await, CI strict-concurrency job); Xcode 26 dynamic framework linking compatibility via explicit `swift-log` (`Logging`) dependency in `Package.swift`. When updating either file, update both and keep terminology and examples consistent. --- diff --git a/AGENT.md b/AGENT.md index ef0ceca..cf3379f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -4,7 +4,7 @@ OpenFCPXMLKit is a modern, fully modular Swift 6 framework for Final Cut Pro FCP Keep this file in sync with `.cursorrules`. Both should describe the same overview, architecture, test structure, and conventions. When you update one, update the other. -**Naming:** Use OpenFCPXMLKit naming exclusively in all code, documentation, comments, and agent files (`ServiceLogger`, `createService()`, `OFKXML*` types). Do not use legacy project names or identifiers from prior forks. Never use the terms "PBF" or "Production's Best Friend" in source code, code comments, symbol names, or CLI/log output; describe the reporting feature neutrally (e.g. "Excel report", "role inventory report", "workbook export"). Those terms may appear only in prose documentation (README, CHANGELOG, Manual, and these agent guides) — never in the codebase itself. +**Naming:** Use OpenFCPXMLKit naming exclusively in all code, documentation, comments, and agent files (`ServiceLogger`, `createService()`, `OFKXML*` types). Do not use legacy project names or identifiers from prior forks. Never use the terms "PBF" or "Production's Best Friend" in source code, code comments, symbol names, or CLI/log output; describe the reporting feature neutrally (e.g. "Excel report", "PDF report", "role inventory report", "workbook export"). Those terms may appear only in prose documentation (README, CHANGELOG, Manual, and these agent guides) — never in the codebase itself. --- @@ -41,7 +41,7 @@ OpenFCPXMLKit targets macOS 26+, iOS 26+, Xcode 26+, and Swift 6.3 with full con **Backward compatibility:** The entire codebase must remain backward compatible with FCPXML 1.5. Optional attributes and elements introduced in later versions (e.g. 1.11, 1.13) must be omitted or ignored when reading/writing or converting to 1.5; mark such features in code comments with the minimum FCPXML version (e.g. `FCPXML 1.13+`). -Current status: **933** tests listed in `swift test --list-tests` (**932** in `OpenFCPXMLKitTests`: 929 XCTest + 3 Swift Testing `@Test`; plus **1** optional `ExcelReportTest` integration); FCPXML versions 1.5–1.14 supported (DTDs included, full parsing, typed element-type coverage for all DTD elements via FCPXMLElementType); Final Cut Pro frame rates (23.976, 24, 25, 29.97, 30, 50, 59.94, 60); thread-safe and concurrency-compliant with comprehensive async/await support; no known security vulnerabilities. Version conversion automatically drops elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); DTD validation runs per version (validateDocumentAgainstDTD, validateDocumentAgainstDeclaredVersion) and after CLI convert. FCPXMLVersion.supportsBundleFormat is true for 1.10+ (.fcpxmld bundle); 1.5–1.9 support only single-file .fcpxml. FCPXML creation: create FCPXML documents from scratch with events, projects, resources, and clips via XMLDocumentManager, XMLDocument initializers, or FCPXMLService. Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment, clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata, timestamps (createdAt, modifiedAt). FCPXMLTimecode: custom timecode type (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, still image asset support. TimelineFormat enhancements: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). Typed adjustment models: Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation with full clip integration. Typed effect/filter models: VideoFilter, AudioFilter, VideoFilterMask with FilterParameter support and keyframe animation (auxValue support FCPXML 1.11+). Typed caption/title models: Caption and Title with TextStyle and TextStyleDefinition for full text formatting. SmartCollection models: SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14). Keyframe animation: KeyframeAnimation, Keyframe with interpolation types, FadeIn/FadeOut with fade types, integrated with FilterParameter. CMTime Codable extension: Direct CMTime encoding/decoding as FCPXML time strings. Collection organization: CollectionFolder and KeywordCollection models for organizing clips and media. Live Drawing (FCPXML 1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (FCPXML 1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye, Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Cross-platform XML abstraction: protocol layer (OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLFactory); Foundation backend on macOS (unchanged behaviour); AEXML backend on iOS; OFKXMLDefaultFactory() for platform dispatch; FCPXMLStructuralValidator for cross-platform structural validation; FCPXMLDTDValidator is platform-conditional (full DTD on macOS, structural fallback on iOS). Comprehensive test coverage: **933** tests across 58 FCPXML sample files including 360 video, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration, secondary storyline detection), keyword collections/folders, Photoshop integration, smart collections, and reporting column layout/exclusion/disabled-clip/workbook-formatting tests. Excel reporting: multi-sheet `.xlsx` workbooks via `FinalCutPro.FCPXML.buildReport(options:)` (ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit); sheets for Role Inventory (**Selected Roles Inventory** + per-role sheets with expanded column layout and dynamic metadata keys), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, **Summary** (project title header row, black role-duration data), and **Media Summary** (red missing-media paths); inventory and section-sheet cell formatting, role exclusions, global column exclusion (`ReportColumn` / `excludedColumns`), disabled-clip filtering (`excludeDisabledClips`), project-name / compound-clip-name filtering (`allReportTimelineSources()`; standalone compound-clip exports without ``), `ReportTimecodeFormat` / `--timecode-format`, inventory-first `ReportBuildPhase` progress callbacks. Extraction presets: Captions, Effects, FrameData, Markers, Roles, Titles. Experimental CLI (OpenFCPXMLKit-CLI): single binary with embedded DTDs; --check-version, --convert-version (stripping + DTD validation), --extension-type (fcpxmld | fcpxml; default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (new empty FCPXML project: --width, --height, --rate, --project-version, output-dir; DTD validation before write; FCP-style output with DOCTYPE, colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-markers, --report-keywords, --report-titles-generators, --report-transitions, --report-effects, --report-speed-change-effects, --report-summary, --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project); --log writes user-visible output for all commands to the log file; see Sources/OpenFCPXMLKitCLI/README.md. +Current status: **944** tests listed in `swift test --list-tests` (**942** in `OpenFCPXMLKitTests`: 939 XCTest + 3 Swift Testing `@Test`; plus **2** optional `ExcelReportTest` integration tests); FCPXML versions 1.5–1.14 supported (DTDs included, full parsing, typed element-type coverage for all DTD elements via FCPXMLElementType); Final Cut Pro frame rates (23.976, 24, 25, 29.97, 30, 50, 59.94, 60); thread-safe and concurrency-compliant with comprehensive async/await support; no known security vulnerabilities. Version conversion automatically drops elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); DTD validation runs per version (validateDocumentAgainstDTD, validateDocumentAgainstDeclaredVersion) and after CLI convert. FCPXMLVersion.supportsBundleFormat is true for 1.10+ (.fcpxmld bundle); 1.5–1.9 support only single-file .fcpxml. FCPXML creation: create FCPXML documents from scratch with events, projects, resources, and clips via XMLDocumentManager, XMLDocument initializers, or FCPXMLService. Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment, clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata, timestamps (createdAt, modifiedAt). FCPXMLTimecode: custom timecode type (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, still image asset support. TimelineFormat enhancements: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). Typed adjustment models: Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation with full clip integration. Typed effect/filter models: VideoFilter, AudioFilter, VideoFilterMask with FilterParameter support and keyframe animation (auxValue support FCPXML 1.11+). Typed caption/title models: Caption and Title with TextStyle and TextStyleDefinition for full text formatting. SmartCollection models: SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14). Keyframe animation: KeyframeAnimation, Keyframe with interpolation types, FadeIn/FadeOut with fade types, integrated with FilterParameter. CMTime Codable extension: Direct CMTime encoding/decoding as FCPXML time strings. Collection organization: CollectionFolder and KeywordCollection models for organizing clips and media. Live Drawing (FCPXML 1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (FCPXML 1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye, Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Cross-platform XML abstraction: protocol layer (OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLFactory); Foundation backend on macOS (unchanged behaviour); AEXML backend on iOS; OFKXMLDefaultFactory() for platform dispatch; FCPXMLStructuralValidator for cross-platform structural validation; FCPXMLDTDValidator is platform-conditional (full DTD on macOS, structural fallback on iOS). Comprehensive test coverage: **944** tests across 58 FCPXML sample files including 360 video, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration, secondary storyline detection), keyword collections/folders, Photoshop integration, smart collections, and reporting column layout/exclusion/disabled-clip/workbook/PDF formatting tests. Excel and PDF reporting: multi-sheet `.xlsx` workbooks via `FinalCutPro.FCPXML.buildReport(options:)` (ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit) and optional `.pdf` via `ReportPDFExport` (CoreGraphics; cover page, dynamic TOC, section pagination; same Report configuration as Excel); sheets for Role Inventory (**Selected Roles Inventory** + per-role sheets with expanded column layout and dynamic metadata keys), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, **Summary** (project title header row, black role-duration data), and **Media Summary** (red missing-media paths); inventory and section-sheet cell formatting, role exclusions, global column exclusion (`ReportColumn` / `excludedColumns`), disabled-clip filtering (`excludeDisabledClips`), project-name / compound-clip-name filtering (`allReportTimelineSources()`; standalone compound-clip exports without ``), `ReportTimecodeFormat` / `--timecode-format`, inventory-first `ReportBuildPhase` progress callbacks. Extraction presets: Captions, Effects, FrameData, Markers, Roles, Titles. Experimental CLI (OpenFCPXMLKit-CLI): single binary with embedded DTDs; --check-version, --convert-version (stripping + DTD validation), --extension-type (fcpxmld | fcpxml; default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (new empty FCPXML project: --width, --height, --rate, --project-version, output-dir; DTD validation before write; FCP-style output with DOCTYPE, colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-markers, --report-keywords, --report-titles-generators, --report-transitions, --report-effects, --report-speed-change-effects, --report-summary, --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project, --create-pdf); --log writes user-visible output for all commands to the log file; see Sources/OpenFCPXMLKitCLI/README.md. Xcode 26 dynamic linking compatibility: `swift-log` (`Logging`) is an explicit direct dependency in `Package.swift` to satisfy stricter transitive dylib linking rules when building OpenFCPXMLKit as a dynamic framework. @@ -53,7 +53,7 @@ The project was fully rewritten and refactored to achieve: - A protocol-oriented design: parsing, timecode conversion, XML manipulation, error handling, MIME type detection, asset validation, silence detection, asset duration measurement, and parallel file I/O are defined as protocols (e.g. FCPXMLParsing, TimecodeConversion, XMLDocumentOperations, ErrorHandling, MIMETypeDetection, AssetValidation, SilenceDetection, AssetDurationMeasurement, ParallelFileIO) with sync and async/await methods. - A single injection point for extension APIs that cannot take parameters: `FCPXMLUtility.defaultForExtensions` (concurrency-safe). No hidden concrete types in extensions; for custom services use the modular API with the `using:` parameter. -- Consistent source layout: Analysis, Classes, Delegates, Errors, Extensions (including +Modular and +Codable), Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model (with subfolders), Parsing, Extraction, **Reporting** (including **Excel/** for XLKit workbook export), **XML** (Protocols: OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLDTDProtocol, OFKXMLFactory; Foundation/: Foundation backends; AEXML/: AEXML backends; OFKXMLDefaultFactory), and FCPXML DTDs. +- Consistent source layout: Analysis, Classes, Delegates, Errors, Extensions (including +Modular and +Codable), Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model (with subfolders), Parsing, Extraction, **Reporting** (including **Excel/** for XLKit workbook export and **PDF/** for CoreGraphics PDF export), **XML** (Protocols: OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLDTDProtocol, OFKXMLFactory; Foundation/: Foundation backends; AEXML/: AEXML backends; OFKXMLDefaultFactory), and FCPXML DTDs. - A structured test suite: shared resources, file tests per sample, logic/parsing tests, timeline/export/validation tests, API and edge-case tests, and performance tests, all documented in Tests/README.md. Foundation XML types (XMLDocument, XMLElement) and the protocol types that wrap them (OFKXMLDocument, OFKXMLElement) and SwiftTimecode types are not Sendable. The codebase avoids Task-based concurrency for these types but provides async/await APIs that are concurrency-safe for Swift 6. If these dependencies become Sendable in the future, further parallelisation can be introduced. @@ -73,7 +73,7 @@ Foundation XML types (XMLDocument, XMLElement) and the protocol types that wrap - Logging: ServiceLogger protocol with levels trace, debug, info, notice, warning, error, critical (ServiceLogLevel); NoOpServiceLogger, PrintServiceLogger, FileServiceLogger (file + optional console, quiet). FCPXMLUtility and FCPXMLService use injected logger for parse, conversion, validation, save, and media operations. CLI: --log, --log-level, --quiet; when --log is set, all CLI commands (check-version, convert-version, validate, media-copy, create-project) write their user-visible messages to the log file. Extension types use #if canImport(Logging) as fallback where DI is not available. - Versioning: FCPXMLVersion (DTD validation, 1.5-1.14) and FinalCutPro.FCPXML.Version (parsing, 1.0-1.14) are bridged via .fcpxmlVersion, .dtdVersion, and init(from:) converters. init(from:) uses safe fallback to `.latest` instead of force unwrap. - Errors: Module-scoped error types (FCPXMLError for parsing, FCPXMLLoadError for file I/O, FCPXMLExportError/FCPXMLBundleExportError for export, FinalCutPro.FCPXML.ParseError with LocalizedError). Parse failures from FCPXMLFileLoader surface as FCPXMLError.parsingFailed so consumers handle a single parse-error type. FCPXMLElementError uses String element names for Sendable compliance. -- Reporting: Excel report builders in `Reporting/` consume Extraction/Model; they map extracted facts to row/section models (role inventory, markers, keywords, titles, transitions, effects, speed-change effects, summary, media summary) and serialise to XLKit workbooks under `Reporting/Excel/` via `FinalCutPro.FCPXML.buildReport(options:)` and `ReportExcelExport`. Timeline resolution: `FinalCutPro.FCPXML.allReportTimelineSources()` / `ReportTimelineSource` (project sequences plus event-level compound clips `ref-clip` → `media`/`sequence` when no ``); `ReportOptions.projectName` / CLI `--report-project` match project or compound-clip names; prefer a real project when both exist. Key options: `excludeDisabledClips`, `excludedColumns` (resolved to `ReportColumn` on `Report`), `timecodeFormat` (`ReportTimecodeFormat`: SMPTE frames default with DF/NDF via SwiftTimecode `stringValue()`, Frames, Feet+Frames, HH:MM:SS), `includeMediaSummary`, `mediaBaseURL`. Build / progress order is `ReportBuildPhase.enabledPhases(for:)` (inventory-first product order shared by builder, CLI, and GUI). Support types include `RoleInventoryColumnLayout`, `ReportColumnExclusion` (matches format-suffixed headers), `ReportFormatting` (format-aware cells; numeric sort for Frames / Feet+Frames). **`FCPXMLReportWorkbookExporter`** owns workbook cell formatting only: format-aware column headers, inventory rows tinted by role category (video/caption blue, titles purple, audio green, gap gray); section sheets use `RoleRowColorContext` when Category is absent (Keywords always blue; Effects/Speed Change infer from role name; Titles purple; Transitions gray); Summary uses a header-style project title and black data rows; Media Summary uses red missing-media paths; Markers use marker-type colours; tabular headers use black fill and white text. When FCPXML grows richer, extend Model/Parsing first, then Extraction, then Reporting presentation only. See ARCHITECTURE.md §2.7. +- Reporting: Excel and PDF report builders in `Reporting/` consume Extraction/Model; they map extracted facts to row/section models and serialise to XLKit workbooks (`Reporting/Excel/` via `ReportExcelExport`) and CoreGraphics PDFs (`Reporting/PDF/` via `ReportPDFExport`). Build `Report` once with `FinalCutPro.FCPXML.buildReport(options:)`; export to Excel, PDF, or both with the same section flags, `excludedColumns`, `timecodeFormat`, and role/disabled-clip filtering. Timeline resolution: `allReportTimelineSources()` / `ReportTimelineSource`; `ReportOptions.projectName` / CLI `--report-project`. Build / progress order: `ReportBuildPhase.enabledPhases(for:)` (inventory-first). Shared row colours: `FCPXMLReportRowColorPolicy` (Excel and PDF). Support: `RoleInventoryColumnLayout`, `ReportColumnExclusion`, `ReportFormatting`, `FCPXMLReportWorkbookExporter` (Excel cell formatting). Extend Model/Parsing and Extraction before Reporting presentation. See ARCHITECTURE.md §2.7. --- @@ -148,7 +148,7 @@ Source layout under Sources/OpenFCPXMLKit/: - Model: FCPXML element models for the parsing layer (previously nested under FinalCutPro/FCPXML/). Subfolders: Adjustments (CropAdjustment, TransformAdjustment, BlendAdjustment, StabilizationAdjustment, VolumeAdjustment, LoudnessAdjustment, NoiseReductionAdjustment, HumReductionAdjustment, EqualizationAdjustment, MatchEqualizationAdjustment, Transform360Adjustment, ColorConformAdjustment, Stereo3DAdjustment, VoiceIsolationAdjustment), Animations (KeyframeAnimation, Keyframe, FadeIn, FadeOut, FadeType), Attributes (AudioLayout, AudioRate, ClipSourceEnable, FrameSampling, TimecodeFormat), Clips (AssetClip, Audio, Audition, Clip including Clip+Adjustments, Gap, MCClip, MulticamSource, RefClip, SyncClip, SyncSource, Title including Title+Typed, Transition, Video), CommonElements (AudioChannelSource, AudioRoleSource, ConformRate, MediaRep, Metadata, Text, TextStyle, TextStyleDefinition, TimeMap), ElementTypes (AnyElementModelType, ElementModelType, ElementType, protocols), Filters (VideoFilter, AudioFilter, VideoFilterMask, FilterParameter), Occlusion (ElementOcclusion, Element Occlusion), Protocols (FCPXMLElement, FCPXMLAttribute, element attribute/children/story protocols), Resources (Asset, Effect, Format, Locator, Media, MediaMulticam, ObjectTracker), Roles (AudioRole, CaptionRole, VideoRole, AncestorRoles, AnyRole, RoleType, FCPXMLRole), Structure (Event, Library, Project, CollectionFolder, KeywordCollection, SmartCollection). Root files: AnyTimeline, Caption including Caption+Typed, Keyword, Marker, Sequence, Spine. - Parsing: XML parsing extensions (Attributes, Clip Parsing, Elements Parsing, Metadata Parsing, Resources Parsing, Roles Parsing, Root Parsing, Time and Frame Rate Parsing). - Extraction: Element extraction logic. Subfolders: Context (DisplayClipName, ElementContext, ElementContextItems, ElementContextTools, FrameRateSource), Effects (EffectsCollector, ExtractedEffect), Presets (CaptionsExtractionPreset, EffectsExtractionPreset, FrameDataPreset, MarkersExtractionPreset, RolesExtractionPreset, TitlesExtractionPreset, FCPXMLExtractionPreset). Root files: Extract, ExtractableChildren, ExtractedElement, ExtractedElementStruct, ExtractedModelElement, Extraction, ExtractionScope. -- Reporting: Excel workbook reports. Top-level: Report, ReportOptions, ReportBuilder (resolves timelines via `allReportTimelineSources()` / `ReportTimelineSource`), ReportTimecodeFormat, ReportBuildProgress (`ReportBuildPhase.enabledPhases(for:)` — inventory-first). Subfolders: Builders (RoleInventory, Markers, Keywords, Titles, Transitions, Effects, SpeedChangeEffects, Summary, MediaSummary), Sections and Rows (typed section/row models with `columnHeaders(timecodeFormat:)`), Support (RoleInventoryClipCollector, RoleInventoryRowBuilder, RoleInventoryColumnLayout, RoleInventoryRoleSheetOrdering, RoleInventoryTimelineBounds, ReportFormatting, ReportRoleExclusion, ReportColumnExclusion, ReportClipCategory, EffectsReportPolicy, SpeedChangeFormatting, SummaryRoleDurationAggregator), Excel (ReportExcelExport, FCPXMLReportWorkbookExporter with format-aware headers and RoleRowColorContext cell formatting, ReportWorkbookColumnAutoFit via XLKit). Consumes Extraction; owns presentation only; see ARCHITECTURE.md §2.7 for layer boundaries. +- Reporting: Excel and PDF report export. Top-level: Report, ReportOptions, ReportBuilder (resolves timelines via `allReportTimelineSources()` / `ReportTimelineSource`), ReportTimecodeFormat, ReportBuildProgress (`ReportBuildPhase.enabledPhases(for:)` — inventory-first). Subfolders: Builders (RoleInventory, Markers, Keywords, Titles, Transitions, Effects, SpeedChangeEffects, Summary, MediaSummary), Sections and Rows (typed section/row models with `columnHeaders(timecodeFormat:)`), Support (RoleInventoryClipCollector, RoleInventoryRowBuilder, RoleInventoryColumnLayout, RoleInventoryRoleSheetOrdering, RoleInventoryTimelineBounds, ReportFormatting, ReportRoleExclusion, ReportColumnExclusion, ReportClipCategory, FCPXMLReportRowColorPolicy, EffectsReportPolicy, SpeedChangeFormatting, SummaryRoleDurationAggregator), Excel (ReportExcelExport, FCPXMLReportWorkbookExporter, ReportWorkbookColumnAutoFit via XLKit), PDF (ReportPDFExport, FCPXMLReportPDFExporter, FCPXMLReportPDFCanvas, FCPXMLReportPDFSheetPlan, FCPXMLReportPDFTableLayout, FCPXMLReportPDFStyle, FCPXMLReportPDFCoverNotes). `Report.exportBrandingText` for Excel cover and PDF cover/footer. Consumes Extraction; owns presentation only; see ARCHITECTURE.md §2.7. - FCPXML DTDs: version 1.5-1.14 and README. Maintain this structure; do not introduce new top-level categories without aligning both AGENT.md and .cursorrules. @@ -163,7 +163,7 @@ Tests live under Tests/. The suite is organised as follows. - Tests/FCPXML Samples/FCPXML/: Sample .fcpxml files (58 samples, e.g. 24.fcpxml, Structure.fcpxml, frame-rate samples). File tests and logic tests load these via shared utilities; tests that require a missing sample use XCTSkip. -- Tests/ExcelReportTest/: Optional integration target (1 test). Writes `Output/OFK-Default.xlsx` and `Output/OFK-Full.xlsx` from a local `.fcpxml`/`.fcpxmld` fixture (`Sample.fcpxmld`, `OFK_REPORTING_FCPXML_BUNDLE`, or auto-discovery). Skips when no fixture is present. See Tests/ExcelReportTest/README.md. +- Tests/ExcelReportTest/: Optional integration target (2 tests). Writes `Output/OFK-Default.xlsx`, `Output/OFK-Full.xlsx`, and `Output/OFK-Default.pdf` from a local `.fcpxml`/`.fcpxmld` fixture (`Sample.fcpxmld`, `OFK_REPORTING_FCPXML_BUNDLE`, or auto-discovery). Skips when no fixture is present. See Tests/ExcelReportTest/README.md. - Tests/OpenFCPXMLKitTests/: Test code. - FCPXMLTestResources.swift: Path resolution (packageRoot, fcpxmlSamplesDirectory, urlForFCPXMLSample, FCPXMLSampleName) so samples work from Xcode and `swift test`. @@ -193,7 +193,7 @@ Tests live under Tests/. The suite is organised as follows. - FCPXMLDTDValidatorTests.swift: Per-version DTD validation. - FCPXMLStructuralValidatorTests.swift: Cross-platform structural validation (iOS fallback path). - FCPXMLAEXMLSerializationParityTests.swift: AEXML vs Foundation XML serialization parity. - - Report and extraction tests (FCPXML-prefixed): FCPXMLCompoundClipReportTests, FCPXMLRoleInventoryReportTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests. + - Report and extraction tests (FCPXML-prefixed): FCPXMLCompoundClipReportTests, FCPXMLRoleInventoryReportTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportPDFExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests. - OpenFCPXMLKitTests.swift: Main test class; setUpWithError injects parser, timecodeConverter, documentManager, errorHandler, FCPXMLUtility, FCPXMLService. MARK sections group tests (FCPXMLUtility, FCPXMLService, modular components, async/concurrency, performance, frame rates, time values, FCPXML time strings, time conforming, error handling, document management, element filtering, modular extensions, edge cases, FCPXMLElementType, FCPXMLError, ModularUtilities API, XMLDocument extension, XMLElement extension, parser filter). - FileTests/: One test class per sample or category (e.g. FCPXMLFileTest_24, FCPXMLFileTest_AllSamples, FCPXMLFileTest_FrameRates, FCPXMLFileTest_360Video, FCPXMLFileTest_AuditionSample, FCPXMLFileTest_ImageSample, FCPXMLFileTest_Multicam, FCPXMLFileTest_Photoshop, FCPXMLFileTest_SmartCollection). Each loads one or more samples and asserts parse success, root, version, events, projects, or resources as appropriate. - FCPXMLAudioKeyframeTests: Audio keyframes in adjust-volume (param name="amount" with keyframeAnimation); parsing from FCPXML samples; decibel values (-3dB, -37dB); time values (FCPXML fractional format); fadeIn/fadeOut integration; multiple keyframes in sequence; secondary storyline and nested clip detection; TimelineWithSecondaryStorylineWithAudioKeyframes, TimelineSample file tests. @@ -203,7 +203,7 @@ Tests live under Tests/. The suite is organised as follows. - FCPXMLAPIAndEdgeCaseTests: FCPXMLFileLoader async load(from:), ServiceLogger injection (NoOp, Print), edge cases (empty/invalid/malformed XML, invalid paths), validation types, Live Drawing (1.11+), HiddenClipMarker (1.13+). - FCPXMLPerformanceTests: Parameterised and basic performance tests (timecode conversion, document creation, element filtering). -Test organisation: use descriptive test method names; group related tests logically; include setup and teardown; use meaningful assertions. Test all supported frame rates (Final Cut Pro compatible). Use realistic FCPXML samples and edge cases; validate against actual FCP behaviour where applicable. Current total: **933** tests listed in `swift test --list-tests` (**932** in `OpenFCPXMLKitTests` + **1** optional `ExcelReportTest`) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, cut detection, version conversion stripping, per-version DTD validation, extract-then-copy (CLI --media-copy flow), synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests refactor (lock-based NowBox, do-catch for throwing APIs), Excel reporting (Selected Roles Inventory column layout, Summary/Media Summary split, ReportTimecodeFormat / format-aware headers, inventory-first ReportBuildPhase progress, global column exclusion, disabled-clip filtering, workbook cell formatting via FCPXMLReportWorkbookExporter), 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. +Test organisation: use descriptive test method names; group related tests logically; include setup and teardown; use meaningful assertions. Test all supported frame rates (Final Cut Pro compatible). Use realistic FCPXML samples and edge cases; validate against actual FCP behaviour where applicable. Current total: **944** tests listed in `swift test --list-tests` (**942** in `OpenFCPXMLKitTests` + **2** optional `ExcelReportTest`) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, cut detection, version conversion stripping, per-version DTD validation, extract-then-copy (CLI --media-copy flow), synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests refactor (lock-based NowBox, do-catch for throwing APIs), Excel reporting (Selected Roles Inventory column layout, Summary/Media Summary split, ReportTimecodeFormat / format-aware headers, inventory-first ReportBuildPhase progress, global column exclusion, disabled-clip filtering, workbook cell formatting via FCPXMLReportWorkbookExporter), 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. --- @@ -223,7 +223,7 @@ SwiftTimecode usage: use `Timecode(.realTime(seconds: seconds), at: frameRate)` ## Testing Requirements -Test coverage: unit tests for all public APIs; integration tests for complex workflows; performance tests for time-critical operations; concurrency tests for async operations; test all supported frame rates (Final Cut Pro compatible). Current: **933** tests listed in `swift test --list-tests` (including AEXML serialization parity, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLReportExcelExportTests workbook cell formatting, optional ExcelReportTest integration) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, version conversion, DTD validation, extract-then-copy flow, synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests (lock-based NowBox, do-catch), Excel reporting enhancements, 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. +Test coverage: unit tests for all public APIs; integration tests for complex workflows; performance tests for time-critical operations; concurrency tests for async operations; test all supported frame rates (Final Cut Pro compatible). Current: **944** tests listed in `swift test --list-tests` (including AEXML serialization parity, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLReportColumnExclusionTests, FCPXMLReportExcludeDisabledClipsTests, FCPXMLRoleInventoryColumnLayoutTests, FCPXMLReportExcelExportTests workbook cell formatting, FCPXMLReportPDFExportTests PDF export, optional ExcelReportTest integration) covering all functionality including async/await, timeline manipulation, metadata, timestamps, FCPXMLTimecode, MIME type detection, asset validation, silence detection, asset duration measurement, parallel file I/O, version conversion, DTD validation, extract-then-copy flow, synchronized clip matching, secondary storyline traversal, clip identification, URL resolution, version conversion edge cases, typed adjustment models (including Transform360, ColorConform, Stereo3D, VoiceIsolation), typed effect/filter models, typed caption/title models, smart collections (match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), keyframe animation, audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration), CMTime Codable extension, collection organization, Live Drawing (1.11+), HiddenClipMarker (1.13+), Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps), FCPXMLExporter clip-level metadata export and XML declaration standalone="no", FCPXMLTimelineManipulationTests (lock-based NowBox, do-catch), Excel reporting enhancements, PDF report export, 360 video features, auditions, conform-rate, still images, multicam, secondary storylines, audio keyframes, keyword collections/folders, and Photoshop integration. Test data: use realistic FCPXML samples; include edge cases and error conditions; test all supported frame rates; validate against actual Final Cut Pro output where applicable. @@ -278,7 +278,7 @@ Build requirements: Swift build successful; all tests passing; no warnings or er ## Project-Specific Rules -FCPXML handling: support FCPXML versions 1.5 through 1.14 (DTDs included; full parsing; typed element-type coverage via FCPXMLElementType for all DTD elements); validate against DTD schemas (FCPXMLDTDValidator, FCPXMLService.validateDocumentAgainstDTD/validateDocumentAgainstDeclaredVersion); version conversion (FCPXMLVersionConverter) sets root version and automatically strips elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); FCPXMLVersion.supportsBundleFormat is true for 1.10+ (save as .fcpxmld bundle); 1.5–1.9 support only .fcpxml; handle all supported frame rates (Final Cut Pro compatible); implement proper XML formatting. Media extraction: extract media references (asset media-rep src, locator url) and copy referenced file URLs to a destination directory (MediaExtraction protocol, MediaExtractor). Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment (finds available lanes), clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata on timeline and clips; timestamps (createdAt, modifiedAt) updated on all mutating operations. TimelineFormat: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). FCPXMLTimecode: custom timecode type wrapping Fraction (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection: MIMETypeDetection protocol and MIMETypeDetector implementation (UTType, AVFoundation, file extension fallback). Asset validation: AssetValidation protocol and AssetValidator implementation (existence check, MIME type compatibility, lane rules: negative = audio only, non-negative = video/image/audio); still image asset support (duration=0s). Silence detection: SilenceDetection protocol and SilenceDetector implementation (configurable threshold and minimum duration). Asset duration measurement: AssetDurationMeasurement protocol and AssetDurationMeasurer implementation (AVFoundation-based for audio/video/images). Parallel file I/O: ParallelFileIO protocol and ParallelFileIOExecutor implementation (concurrent read/write operations). SmartCollection: SmartCollection model with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14); library and event integration. Live Drawing (1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye (left|right), Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Excel reporting: multi-sheet `.xlsx` workbooks (`FinalCutPro.FCPXML.buildReport(options:)`, ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit) with Role Inventory (**Selected Roles Inventory** + per-role sheets), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary (project metrics and role durations), and Media Summary (missing media paths); role exclusions, global column exclusion (`excludedColumns` / `ReportColumn`), disabled-clip filtering (`excludeDisabledClips`), `ReportTimecodeFormat` / `--timecode-format` (SMPTE frames with DF/NDF, Frames, Feet+Frames, HH:MM:SS; format-aware headers; numeric sort for Frames/Feet+Frames), inventory-first `ReportBuildPhase.enabledPhases(for:)` progress, and project-name filtering; builders consume Extraction/Model and own presentation only (see ARCHITECTURE.md §2.7). Experimental CLI: OpenFCPXMLKit-CLI single binary (embedded DTDs) with --check-version, --convert-version (stripping + DTD validation), --extension-type fcpxml|fcpxmld (default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (width, height, rate, project-version, output-dir; mandatory DTD validation; FCP-style DOCTYPE, format colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-summary and --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project), and LOG options (--log, --log-level, --quiet; --log records user-visible output for all commands); see Sources/OpenFCPXMLKitCLI/README.md. +FCPXML handling: support FCPXML versions 1.5 through 1.14 (DTDs included; full parsing; typed element-type coverage via FCPXMLElementType for all DTD elements); validate against DTD schemas (FCPXMLDTDValidator, FCPXMLService.validateDocumentAgainstDTD/validateDocumentAgainstDeclaredVersion); version conversion (FCPXMLVersionConverter) sets root version and automatically strips elements not in the target version’s DTD (e.g. adjust-colorConform, adjust-stereo-3D); FCPXMLVersion.supportsBundleFormat is true for 1.10+ (save as .fcpxmld bundle); 1.5–1.9 support only .fcpxml; handle all supported frame rates (Final Cut Pro compatible); implement proper XML formatting. Media extraction: extract media references (asset media-rep src, locator url) and copy referenced file URLs to a destination directory (MediaExtraction protocol, MediaExtractor). Timeline manipulation: ripple insert (shifts subsequent clips), auto lane assignment (finds available lanes), clip queries (by lane, time range, asset ID), lane range computation, secondary storylines. Timeline metadata: markers, chapter markers, keywords, ratings, custom metadata on timeline and clips; timestamps (createdAt, modifiedAt) updated on all mutating operations. TimelineFormat: presets (hd720p, dci4K, hd1080i, hd720i), computed properties (aspectRatio, isHD, isUHD, interlaced). FCPXMLTimecode: custom timecode type wrapping Fraction (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). MIME type detection: MIMETypeDetection protocol and MIMETypeDetector implementation (UTType, AVFoundation, file extension fallback). Asset validation: AssetValidation protocol and AssetValidator implementation (existence check, MIME type compatibility, lane rules: negative = audio only, non-negative = video/image/audio); still image asset support (duration=0s). Silence detection: SilenceDetection protocol and SilenceDetector implementation (configurable threshold and minimum duration). Asset duration measurement: AssetDurationMeasurement protocol and AssetDurationMeasurer implementation (AVFoundation-based for audio/video/images). Parallel file I/O: ParallelFileIO protocol and ParallelFileIOExecutor implementation (concurrent read/write operations). SmartCollection: SmartCollection model with match-clip, match-media, match-ratings, match-text, match-usage (1.9+), match-representation (1.10+), match-markers (1.10+), match-analysis-type (1.14); library and event integration. Live Drawing (1.11+): LiveDrawing model for live-drawing story elements. HiddenClipMarker (1.13+): HiddenClipMarker model for hidden clip markers. Format/Asset 1.13+: Format heroEye (left|right), Asset heroEyeOverride, Asset mediaReps (multiple media-rep). Excel and PDF reporting: multi-sheet `.xlsx` workbooks (`FinalCutPro.FCPXML.buildReport(options:)`, ReportBuilder, ReportOptions presets, ReportExcelExport on XLKit) with Role Inventory (**Selected Roles Inventory** + per-role sheets), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary (project metrics and role durations), and Media Summary (missing media paths); role exclusions, global column exclusion (`excludedColumns` / `ReportColumn`), disabled-clip filtering (`excludeDisabledClips`), `ReportTimecodeFormat` / `--timecode-format` (SMPTE frames with DF/NDF, Frames, Feet+Frames, HH:MM:SS; format-aware headers; numeric sort for Frames/Feet+Frames), inventory-first `ReportBuildPhase.enabledPhases(for:)` progress, and project-name filtering; builders consume Extraction/Model and own presentation only (see ARCHITECTURE.md §2.7). Experimental CLI: OpenFCPXMLKit-CLI single binary (embedded DTDs) with --check-version, --convert-version (stripping + DTD validation), --extension-type fcpxml|fcpxmld (default fcpxmld; 1.5–1.9 always .fcpxml), --validate, --media-copy, --create-project (width, height, rate, project-version, output-dir; mandatory DTD validation; FCP-style DOCTYPE, format colorSpace, default smart collections), --report (Excel report: role inventory by default; --report-full, per-section flags including --report-summary and --report-media-summary, --exclude-role, --exclude-column, --exclude-disabled-clips, --timecode-format, --report-project), and LOG options (--log, --log-level, --quiet; --log records user-visible output for all commands); see Sources/OpenFCPXMLKitCLI/README.md. Timecode operations: use SwiftTimecode for all timecode operations; support all FCPXML frame rates (Final Cut Pro compatible); implement proper frame rate conversions; handle drop frame and non-drop frame timecode. FCPXMLTimecode provides a custom timecode type for FCPXML-specific operations (arithmetic, frame alignment, CMTime conversion, FCPXML string parsing). @@ -310,9 +310,9 @@ Keep AGENT.md and .cursorrules in sync. Both must reflect: changelog styling (CH - Project overview and codebase rewrite/refactor. - Architecture (protocols, implementations, extensions, service, utilities) and single injection point (FCPXMLUtility.defaultForExtensions). -- Source layout (Analysis, Classes, Delegates, Errors, Extensions including +Modular and +Codable, Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model including Adjustments, Animations, Filters, Clips with +Adjustments and +Typed, CommonElements with TextStyle/TextStyleDefinition, Structure with CollectionFolder/KeywordCollection/SmartCollection, Parsing, Extraction, Reporting including Excel/, XML with Protocols/Foundation/AEXML/OFKXMLDefaultFactory, FCPXML DTDs; reporting vs core layers in ARCHITECTURE.md §2.7). -- Test structure (Tests/ layout, FCPXMLTestResources, FCPXMLTestUtilities, FileTests/ including FCPXMLFileTest_360Video, FCPXMLFileTest_AuditionSample, FCPXMLFileTest_ImageSample, FCPXMLFileTest_Multicam, FCPXMLFileTest_Photoshop, FCPXMLFileTest_SmartCollection, LogicAndParsing/ including FCPXMLFormatAssetTests, FCPXMLCutDetectionTests, FCPXMLVersionConversionTests, FCPXMLMediaExtractionTests, FCPXMLTimelineManipulationTests, FCPXMLTimecodeTests, FCPXMLMIMETypeDetectionTests, FCPXMLAssetValidationTests, FCPXMLSilenceDetectionTests, FCPXMLAssetDurationMeasurementTests, FCPXMLParallelFileIOTests, FCPXMLAudioEnhancementTests, FCPXMLTransform360Tests, FCPXMLCaptionTitleTests, FCPXMLKeyframeAnimationTests, FCPXMLAudioKeyframeTests, FCPXMLCMTimeCodableTests, FCPXMLCollectionTests, FCPXMLSmartCollectionTests, FCPXMLAdjustmentTests, FCPXMLFilterTests, FCPXMLImportOptionsTests, FCPXMLCodableTests, FCPXMLAEXMLSerializationParityTests, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, OpenFCPXMLKitTests.swift, FCPXMLTimelineExportValidationTests, FCPXMLAPIAndEdgeCaseTests, FCPXMLPerformanceTests, and report/extraction tests (FCPXMLRoleInventoryReportTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests); every test-case class is FCPXML-prefixed except the module-named umbrella OpenFCPXMLKitTests; empty timeline creation and project-creation export at different sizes and frame rates in FCPXMLTimelineExportValidationTests (clip-level metadata export, XML declaration standalone="no")). Cross-platform XML (OFKXML*, Foundation vs AEXML, FCPXMLStructuralValidator, iOS). -- FCPXML 1.5–1.14 and FCPXMLElementType; FCPXMLVersion.supportsBundleFormat (1.10+); version conversion with element stripping and per-version DTD validation; FCPXML creation from scratch; timeline manipulation (ripple insert, auto lane assignment, clip queries, lane range, secondary storylines); timeline metadata (markers, chapter markers, keywords, ratings, timestamps); FCPXMLTimecode custom type; MIME type detection; asset validation (including still images); silence detection; asset duration measurement; parallel file I/O; TimelineFormat enhancements; typed adjustment models (Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation); typed effect/filter models (VideoFilter, AudioFilter, VideoFilterMask, FilterParameter with keyframe animation and auxValue 1.11+); typed caption/title models (Caption, Title with TextStyle, TextStyleDefinition); smart collections (SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type); keyframe animation (KeyframeAnimation, Keyframe, FadeIn, FadeOut); audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration); CMTime Codable extension; collection organization (CollectionFolder, KeywordCollection); Live Drawing (1.11+); HiddenClipMarker (1.13+); Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps); experimental CLI (OpenFCPXMLKit-CLI, single binary, embedded DTDs, --check-version, --convert-version, --extension-type fcpxml|fcpxmld, --validate, --media-copy, --create-project with DTD validation and FCP-style output, --report Excel report with --report-full/per-section flags/--report-summary/--report-media-summary/--exclude-role/--exclude-column/--exclude-disabled-clips/--timecode-format/--report-project, --log/--log-level/--quiet with log file capturing all command output); Excel reporting subsystem (`allReportTimelineSources` / compound-clip timelines) (Reporting/ builders, Sections/Rows, Support including RoleInventoryColumnLayout, ReportColumnExclusion, ReportFormatting, ReportTimecodeFormat, ReportBuildProgress; Excel/ via XLKit; buildReport/ReportExcelExport; Summary and Media Summary sheets; excludeDisabledClips/excludedColumns/timecodeFormat; inventory-first enabledPhases); extraction presets (Captions, Effects, FrameData, Markers, Roles, Titles); Tests/ExcelReportTest optional integration; Final Cut Pro frame rates; Swift 6 concurrency (Sendable, async/await, CI strict-concurrency job); Xcode 26 dynamic framework linking compatibility via explicit `swift-log` (`Logging`) dependency in `Package.swift`. +- Source layout (Analysis, Classes, Delegates, Errors, Extensions including +Modular and +Codable, Implementations, Protocols, Services, Utilities, Annotations, Export, Timeline, Timing, Validation, FileIO, Logging, Format, Model including Adjustments, Animations, Filters, Clips with +Adjustments and +Typed, CommonElements with TextStyle/TextStyleDefinition, Structure with CollectionFolder/KeywordCollection/SmartCollection, Parsing, Extraction, Reporting including Excel/ and PDF/, XML with Protocols/Foundation/AEXML/OFKXMLDefaultFactory, FCPXML DTDs; reporting vs core layers in ARCHITECTURE.md §2.7). +- Test structure (Tests/ layout, FCPXMLTestResources, FCPXMLTestUtilities, FileTests/ including FCPXMLFileTest_360Video, FCPXMLFileTest_AuditionSample, FCPXMLFileTest_ImageSample, FCPXMLFileTest_Multicam, FCPXMLFileTest_Photoshop, FCPXMLFileTest_SmartCollection, LogicAndParsing/ including FCPXMLFormatAssetTests, FCPXMLCutDetectionTests, FCPXMLVersionConversionTests, FCPXMLMediaExtractionTests, FCPXMLTimelineManipulationTests, FCPXMLTimecodeTests, FCPXMLMIMETypeDetectionTests, FCPXMLAssetValidationTests, FCPXMLSilenceDetectionTests, FCPXMLAssetDurationMeasurementTests, FCPXMLParallelFileIOTests, FCPXMLAudioEnhancementTests, FCPXMLTransform360Tests, FCPXMLCaptionTitleTests, FCPXMLKeyframeAnimationTests, FCPXMLAudioKeyframeTests, FCPXMLCMTimeCodableTests, FCPXMLCollectionTests, FCPXMLSmartCollectionTests, FCPXMLAdjustmentTests, FCPXMLFilterTests, FCPXMLImportOptionsTests, FCPXMLCodableTests, FCPXMLAEXMLSerializationParityTests, FCPXMLDTDValidatorTests, FCPXMLStructuralValidatorTests, OpenFCPXMLKitTests.swift, FCPXMLTimelineExportValidationTests, FCPXMLAPIAndEdgeCaseTests, FCPXMLPerformanceTests, and report/extraction tests (FCPXMLRoleInventoryReportTests, FCPXMLMarkersReportTests, FCPXMLKeywordsReportTests, FCPXMLTitlesReportTests, FCPXMLTransitionsReportTests, FCPXMLEffectsReportTests, FCPXMLSpeedChangeEffectsReportTests, FCPXMLSummaryReportTests, FCPXMLReportExcelExportTests, FCPXMLReportPDFExportTests, FCPXMLReportFormattingTests, FCPXMLReportRoleExclusionTests, FCPXMLReportTimecodeFormatTests, FCPXMLReportBuildPhaseTests, FCPXMLRoleDisplayPreferenceTests, FCPXMLRoleInventoryClipCollectorTests, FCPXMLRoleInventoryRoleSheetOrderingTests, FCPXMLSummaryRoleDurationAggregatorTests, FCPXMLEffectsReportPolicyTests, FCPXMLSpeedChangeFormattingTests, FCPXMLDisplayClipNameTests, FCPXMLTitleDisplayTests, FCPXMLExtractionScopeTests, FCPXMLExtractedElementTests, FCPXMLEffectsCollectorTests, FCPXMLRolesExtractionPresetTests, FCPXMLEffectAppleSuppliedTests, FCPXMLClipParsingCarriesAudioTests, FCPXMLTransformAdjustmentParsingTests, FCPXMLTransitionSpinePlacementTests); every test-case class is FCPXML-prefixed except the module-named umbrella OpenFCPXMLKitTests; empty timeline creation and project-creation export at different sizes and frame rates in FCPXMLTimelineExportValidationTests (clip-level metadata export, XML declaration standalone="no")). Cross-platform XML (OFKXML*, Foundation vs AEXML, FCPXMLStructuralValidator, iOS). +- FCPXML 1.5–1.14 and FCPXMLElementType; FCPXMLVersion.supportsBundleFormat (1.10+); version conversion with element stripping and per-version DTD validation; FCPXML creation from scratch; timeline manipulation (ripple insert, auto lane assignment, clip queries, lane range, secondary storylines); timeline metadata (markers, chapter markers, keywords, ratings, timestamps); FCPXMLTimecode custom type; MIME type detection; asset validation (including still images); silence detection; asset duration measurement; parallel file I/O; TimelineFormat enhancements; typed adjustment models (Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation); typed effect/filter models (VideoFilter, AudioFilter, VideoFilterMask, FilterParameter with keyframe animation and auxValue 1.11+); typed caption/title models (Caption, Title with TextStyle, TextStyleDefinition); smart collections (SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type); keyframe animation (KeyframeAnimation, Keyframe, FadeIn, FadeOut); audio keyframes (FCPXMLAudioKeyframeTests: adjust-volume param keyframeAnimation parsing, decibel/time validation, fadeIn/fadeOut integration); CMTime Codable extension; collection organization (CollectionFolder, KeywordCollection); Live Drawing (1.11+); HiddenClipMarker (1.13+); Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps); experimental CLI (OpenFCPXMLKit-CLI, single binary, embedded DTDs, --check-version, --convert-version, --extension-type fcpxml|fcpxmld, --validate, --media-copy, --create-project with DTD validation and FCP-style output, --report Excel/PDF report with --report-full/per-section flags/--report-summary/--report-media-summary/--exclude-role/--exclude-column/--exclude-disabled-clips/--timecode-format/--report-project/--create-pdf, --log/--log-level/--quiet with log file capturing all command output); Excel and PDF reporting subsystem (`allReportTimelineSources` / compound-clip timelines) (Reporting/ builders, Sections/Rows, Support including RoleInventoryColumnLayout, ReportColumnExclusion, ReportFormatting, ReportTimecodeFormat, ReportBuildProgress; Excel/ via XLKit; buildReport/ReportExcelExport/ReportPDFExport; Summary and Media Summary sheets; excludeDisabledClips/excludedColumns/timecodeFormat; inventory-first enabledPhases); extraction presets (Captions, Effects, FrameData, Markers, Roles, Titles); Tests/ExcelReportTest optional integration; Final Cut Pro frame rates; Swift 6 concurrency (Sendable, async/await, CI strict-concurrency job); Xcode 26 dynamic framework linking compatibility via explicit `swift-log` (`Logging`) dependency in `Package.swift`. When updating either file, apply the same information to both and keep terminology and examples consistent. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f0475e4..08a6df2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,7 +16,7 @@ OpenFCPXMLKit is a **Swift 6** framework for Final Cut Pro FCPXML: parsing, crea - **Repository:** https://github.com/TheAcharya/OpenFCPXMLKit - **Dependencies:** SwiftTimecode 3.1.2+, SwiftExtensions 2.2.0+, swift-log 1.14.0+, AEXML 4.7.0+, swift-argument-parser 1.8.2+ (CLI only), Foundation, CoreMedia. - **FCPXML:** Versions 1.5–1.14 (DTDs included); Final Cut Pro frame rates (23.976, 24, 25, 29.97, 30, 50, 59.94, 60). -- **Tests:** **933** tests listed in `swift test --list-tests` — **932** in `OpenFCPXMLKitTests` (929 XCTest `func test` + 3 Swift Testing `@Test`) and **1** optional `ExcelReportTest` integration; **58** sample `.fcpxml` files under `Tests/FCPXML Samples/FCPXML/`. +- **Tests:** **944** tests listed in `swift test --list-tests` — **942** in `OpenFCPXMLKitTests` (939 XCTest `func test` + 3 Swift Testing `@Test`) and **2** optional `ExcelReportTest` integration tests; **58** sample `.fcpxml` files under `Tests/FCPXML Samples/FCPXML/`. --- @@ -106,9 +106,9 @@ Reporting/ Row models, builders, sheet-specific presentation rules **3. Reporting** — Keep thin: - Builders call `fcpExtract` and shared context helpers (`ElementContext`, effect/title contexts). -- Sheet-specific **presentation policy** only: column order, string formatting (including `ReportTimecodeFormat`), sort order (numeric for Frames / Feet+Frames), inclusion allowlists (e.g. which custom filters appear on an effects sheet), workbook column exclusion (`ReportColumn`, `ReportColumnExclusion`), format-aware timecode column headers, workbook cell colours (`FCPXMLReportWorkbookExporter`, `RoleRowColorContext`), and optional cover-sheet branding. +- Sheet-specific **presentation policy** only: column order, string formatting (including `ReportTimecodeFormat`), sort order (numeric for Frames / Feet+Frames), inclusion allowlists (e.g. which custom filters appear on an effects sheet), global column exclusion (`ReportColumn`, `ReportColumnExclusion`), format-aware timecode column headers, shared row text colours (`FCPXMLReportRowColorPolicy` — used by Excel and PDF), workbook/PDF cell colours (`FCPXMLReportWorkbookExporter`, `RoleRowColorContext` on Excel; PDF applies the same policy via CoreGraphics), and optional cover-sheet / cover-page branding (`Report.exportBrandingText`). - `ReportOptions.excludeDisabledClips` flows into `ExtractionScope.includeDisabled` at build time so disabled clips are omitted consistently across all timeline-based sections. -- `ReportOptions.timecodeFormat` is stored on `Report.timecodeFormat` and drives cell strings plus Excel header suffixes (e.g. `Timeline In (frames)`). +- `ReportOptions.timecodeFormat` is stored on `Report.timecodeFormat` and drives cell strings plus Excel/PDF header suffixes (e.g. `Timeline In (frames)`). - Build / progress order is **`ReportBuildPhase.enabledPhases(for:)`** (product / workbook order: Selected Roles Inventory first, then Markers … Media Summary). `ReportBuilder` and CLI/GUI progress share this list. - **Timeline resolution** for `buildReport` / `ReportBuilder` uses **`FinalCutPro.FCPXML.allReportTimelineSources()`** (defined on `FCPXMLProperties`): every `` sequence, plus event-level compound clips (`ref-clip` → `media`/`sequence`) when FCP exported a compound clip with no ``. Prefer a real project when both exist. `ReportOptions.projectName` / CLI `--report-project` match project or compound-clip display names. Discovery belongs on `FCPXML` (Classes); Reporting only consumes `ReportTimelineSource`. - Do **not** duplicate timeline math, role resolution, or element traversal that belongs in Extraction/Model. @@ -122,11 +122,11 @@ Reporting/ Row models, builders, sheet-specific presentation rules | Discover project vs standalone compound-clip report timelines | Classes (`allReportTimelineSources` / `ReportTimelineSource`); Reporting resolves via that API | | Which rows appear on a given workbook sheet | Reporting | | Column labels, timecode strings, enabled checkmarks | Reporting | -| Timecode display mode (SMPTE / Frames / Feet+Frames / HH:MM:SS) and header suffixes | `ReportOptions.timecodeFormat` → `Report.timecodeFormat` → Formatting + Excel export | +| Timecode display mode (SMPTE / Frames / Feet+Frames / HH:MM:SS) and header suffixes | `ReportOptions.timecodeFormat` → `Report.timecodeFormat` → Formatting + Excel/PDF export | | Build / progress / GUI section order | `ReportBuildPhase.enabledPhases(for:)` | | Omit `enabled="0"` clips from all timeline sections | `ReportOptions.excludeDisabledClips` → Extraction scope | -| Omit named columns from every applicable sheet | `ReportOptions.excludedColumns` → `Report.excludedColumns` → Excel export | -| Workbook cell colours (inventory role category; section-sheet `RoleRowColorContext`; Summary header-style title + black data; Media Summary red paths; marker-type colours) | `FCPXMLReportWorkbookExporter` | +| Omit named columns from every applicable sheet | `ReportOptions.excludedColumns` → `Report.excludedColumns` → Excel and PDF export | +| Workbook/PDF row text colours (inventory role category; section-sheet inference; Summary header-style title + black data; Media Summary red paths; marker-type colours) | `FCPXMLReportRowColorPolicy` (+ `FCPXMLReportWorkbookExporter` on Excel; PDF renderer applies same policy) | | Missing media path list | Media Summary builder (`mediaBaseURL` for relative paths) | **Workflow when a report gap appears** @@ -138,6 +138,8 @@ Reporting/ Row models, builders, sheet-specific presentation rules **Excel export** lives under **`Reporting/Excel/`** and serialises `Report` to XLKit workbooks via `ReportExcelExport` and `FCPXMLReportWorkbookExporter`; it applies column exclusion (including format-suffixed timecode headers), `Report.timecodeFormat` header suffixes, tabular header styling (black fill, white bold text), and sheet-specific row text colours but should not introduce new FCPXML interpretation. +**PDF export** lives under **`Reporting/PDF/`** and serialises the **same** `Report` to a multi-page A4 landscape PDF via `ReportPDFExport` and `FCPXMLReportPDFExporter` (CoreGraphics). Build the report once; export to Excel, PDF, or both. PDF respects the same section flags, `excludedColumns`, `timecodeFormat`, role/disabled-clip filtering, and row colours. PDF-only presentation: cover page, dynamic table of contents (two-pass page numbering), per-sheet tinted content zones, horizontal/vertical table pagination, and ellipsis truncation. CLI: `--create-pdf` (with `--report`). + --- ## 3. Project structure @@ -160,8 +162,8 @@ flowchart TB SRC --> CLI["OpenFCPXMLKitCLI → OpenFCPXMLKit-CLI"] SRC --> GEN["GenerateEmbeddedDTDs"] - TST --> OKT["OpenFCPXMLKitTests — 932 tests"] - TST --> ERT["ExcelReportTest — 1 optional integration"] + TST --> OKT["OpenFCPXMLKitTests — 942 tests"] + TST --> ERT["ExcelReportTest — 2 optional integration tests"] TST --> SMP["FCPXML Samples/ — 58 .fcpxml files"] ``` @@ -174,7 +176,7 @@ flowchart TB PRS["Parsing/ — attributes, clips, roles, time"] MDL["Model/ — typed elements, adjustments, filters, roles"] EXT["Extraction/ — fcpExtract · Context · Presets"] - REP["Reporting/ — Report builders · Sections · Excel export"] + REP["Reporting/ — Report builders · Sections · Excel · PDF export"] DTD --> XML --> PRS --> MDL --> EXT --> REP ``` @@ -188,10 +190,13 @@ flowchart TB R_TOP["Report · ReportOptions · ReportBuilder · ReportTimecodeFormat · ReportBuildProgress"] R_BLD["Builders/ — RoleInventory · Markers · Keywords · Titles · Transitions · Effects · SpeedChange · Summary · MediaSummary"] R_SEC["Sections/ + Rows/ — typed sheet models · format-aware columnHeaders"] - R_SUP["Support/ — RoleInventoryClipCollector · RoleInventoryColumnLayout · ReportColumnExclusion · ReportFormatting · …"] + R_SUP["Support/ — RoleInventoryClipCollector · RoleInventoryColumnLayout · ReportColumnExclusion · ReportFormatting · FCPXMLReportRowColorPolicy · …"] R_XLS["Excel/ — ReportExcelExport · FCPXMLReportWorkbookExporter · ColumnAutoFit"] + R_PDF["PDF/ — ReportPDFExport · FCPXMLReportPDFExporter · Canvas · SheetPlan · TableLayout · Style"] R_TOP --> R_BLD --> R_SEC - R_BLD --> R_SUP --> R_XLS + R_BLD --> R_SUP + R_SUP --> R_XLS + R_SUP --> R_PDF end subgraph CLI_DETAIL["OpenFCPXMLKitCLI/"] @@ -265,7 +270,7 @@ Source layout under **`Sources/OpenFCPXMLKit/`**: | **Model** | FCPXML element models: Adjustments, Animations, Attributes, Clips, CommonElements, ElementTypes, Filters, Occlusion, Protocols, Resources, Roles, Structure (CollectionFolder, KeywordCollection, etc.). | | **Parsing** | XML parsing extensions (Attributes, Clip, Elements, Metadata, Resources, Roles, Root, Time and Frame Rate). | | **Extraction** | `fcpExtract`, ExtractedElement, ExtractionScope, ExtractableChildren. **Context/** (DisplayClipName, ElementContext, ElementContextItems/Tools, FrameRateSource), **Effects/** (EffectsCollector, ExtractedEffect), **Presets/** (Captions, Effects, FrameData, Markers, Roles, Titles, plus the base ExtractionPreset). | -| **Reporting** | Excel workbook reports. Top-level: `Report`, `ReportOptions`, `ReportBuilder`, `ReportTimecodeFormat` (`.smpteFrames` / `.frames` / `.feetAndFrames` / `.smpteNoFrames`), `ReportBuildProgress` (`ReportBuildPhase.enabledPhases(for:)` — inventory-first product order shared by builder, CLI, and GUI progress). **Builders/** — per-sheet builders including `MediaSummaryReportBuilder` (missing media paths) and `SummaryReportBuilder` (project metrics + role durations); build loop follows enabled phases. **Sections/** and **Rows/** — typed section/row models with `columnHeaders(timecodeFormat:)` (Selected Roles Inventory, Markers, Keywords, Titles & Generators, Transitions, Effects, Speed Change Effects, Summary, Media Summary). **Support/** — `RoleInventoryClipCollector`, `RoleInventoryRowBuilder`, `RoleInventoryColumnLayout` (Row + 23 fixed columns + dynamic metadata keys; format-aware headers), `RoleInventoryRoleSheetOrdering`, `RoleInventoryTimelineBounds`, `ReportFormatting` (SMPTE DF/NDF via SwiftTimecode `stringValue()`, Frames / Feet+Frames / HH:MM:SS; numeric `compareTimelinePositions` for Frames and Feet+Frames), `ReportRoleExclusion`, `ReportColumnExclusion` (`ReportColumn` global column omission; matches format-suffixed headers), `ReportClipCategory`, `EffectsReportPolicy`, `SpeedChangeFormatting`, `SummaryRoleDurationAggregator`. **Excel/** — `ReportExcelExport`, `FCPXMLReportWorkbookExporter` (applies `Report.excludedColumns`, `Report.timecodeFormat` header suffixes, `RoleRowColorContext` sheet-specific row colours, inventory role-category tints, Summary project-title header + black data rows, Media Summary red missing-media paths, marker-type colours), `ReportWorkbookColumnAutoFit`. `ReportOptions`: `excludeDisabledClips`, `excludedColumns`, `timecodeFormat`, `includeMediaSummary`, `mediaBaseURL`, `projectName` (project or compound-clip name). Timeline pick via `allReportTimelineSources()` (see §2.7). Consumes Extraction; owns presentation only. | +| **Reporting** | Excel and PDF report export. Top-level: `Report`, `ReportOptions`, `ReportBuilder`, `ReportTimecodeFormat` (`.smpteFrames` / `.frames` / `.feetAndFrames` / `.smpteNoFrames`), `ReportBuildProgress` (`ReportBuildPhase.enabledPhases(for:)` — inventory-first product order shared by builder, CLI, and GUI progress). **Builders/** — per-sheet builders including `MediaSummaryReportBuilder` (missing media paths) and `SummaryReportBuilder` (project metrics + role durations). **Sections/** and **Rows/** — typed section/row models with `columnHeaders(timecodeFormat:)`. **Support/** — `RoleInventoryClipCollector`, `RoleInventoryRowBuilder`, `RoleInventoryColumnLayout`, `RoleInventoryRoleSheetOrdering`, `RoleInventoryTimelineBounds`, `ReportFormatting`, `ReportRoleExclusion`, `ReportColumnExclusion`, `ReportClipCategory`, `FCPXMLReportRowColorPolicy` (shared Excel/PDF row colours), `EffectsReportPolicy`, `SpeedChangeFormatting`, `SummaryRoleDurationAggregator`. **Excel/** — `ReportExcelExport`, `FCPXMLReportWorkbookExporter`, `ReportWorkbookColumnAutoFit`. **PDF/** — `ReportPDFExport`, `FCPXMLReportPDFExporter`, `FCPXMLReportPDFCanvas`, `FCPXMLReportPDFSheetPlan`, `FCPXMLReportPDFTableLayout`, `FCPXMLReportPDFStyle`, `FCPXMLReportPDFCoverNotes`. `ReportOptions`: `excludeDisabledClips`, `excludedColumns`, `timecodeFormat`, `includeMediaSummary`, `mediaBaseURL`, `projectName`. `Report.exportBrandingText` for Excel cover and PDF cover/footer. Timeline pick via `allReportTimelineSources()` (see §2.7). Consumes Extraction; owns presentation only. | | **XML** | Platform-agnostic XML layer: Protocols (OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLDTDProtocol, OFKXMLFactory), Foundation/ (Foundation backends), AEXML/ (AEXML backends), OFKXMLDefaultFactory. | | **FCPXML DTDs** | Version 1.5–1.14 DTDs. | @@ -343,7 +348,7 @@ Source layout under **`Sources/OpenFCPXMLKit/`**: - **Cross-platform XML:** Use `OFKXMLDefaultFactory()` when creating documents/elements so iOS gets the AEXML backend. All parsing and model code uses `any OFKXMLDocument` / `any OFKXMLElement`; the concrete type is chosen at runtime. - **Logging:** `ServiceLogger` protocol with `ServiceLogLevel`; inject via `FCPXMLService` / `FCPXMLUtility` or build from CLI `LogOptions.makeLogger()`. - **Service factory:** `ModularUtilities.createService()` returns a fully configured `FCPXMLService`; `createCustomService(...)` accepts custom protocol implementations. -- **Report timeline sources:** `allReportTimelineSources()` discovers project sequences and event-level compound-clip sequences so Excel reporting works for FCP “Export XML” of a compound clip (no ``). +- **Report timeline sources:** `allReportTimelineSources()` discovers project sequences and event-level compound-clip sequences so Excel and PDF reporting work for FCP “Export XML” of a compound clip (no ``). --- @@ -351,16 +356,16 @@ Source layout under **`Sources/OpenFCPXMLKit/`**: Binary name: **`OpenFCPXMLKit-CLI`**. Mutually exclusive modes: `--check-version`, `--convert-version`, `--extension-type` (fcpxmld | fcpxml), `--validate`, `--media-copy`, `--report`, `--create-project` (requires `--width`, `--height`, `--rate`, `--project-version`, output-dir). -**`--report`** builds an Excel workbook from a normal project **or** a standalone compound-clip export (role inventory by default — **Selected Roles Inventory** + per-role sheets). `--report-full` adds every optional sheet. Per-section flags: `--report-markers`, `--report-keywords`, `--report-titles-generators`, `--report-transitions`, `--report-effects`, `--report-speed-change-effects`, `--report-summary`, `--report-media-summary`. Filtering: `--exclude-role` (repeatable), `--exclude-column` (repeatable; global column omission), `--exclude-disabled-clips` (omit `enabled="0"` clips), `--report-project` (project or compound-clip name). Timecode cells: `--timecode-format` (`HH:MM:SS:FF` default, `Frames`, `Feet+Frames`, `HH:MM:SS`). Progress labels follow `ReportBuildPhase.enabledPhases(for:)` (inventory first). Log options: `--log`, `--log-level`, `--quiet`. See `Sources/OpenFCPXMLKitCLI/README.md` and `Documentation/Manual/16-CLI.md`. +**`--report`** builds an Excel workbook from a normal project **or** a standalone compound-clip export (role inventory by default — **Selected Roles Inventory** + per-role sheets). `--report-full` adds every optional sheet. Per-section flags: `--report-markers`, `--report-keywords`, `--report-titles-generators`, `--report-transitions`, `--report-effects`, `--report-speed-change-effects`, `--report-summary`, `--report-media-summary`. **`--create-pdf`** also writes a `.pdf` from the same built `Report` (sections, column exclusions, timecode format). Filtering: `--exclude-role` (repeatable), `--exclude-column` (repeatable; global column omission), `--exclude-disabled-clips` (omit `enabled="0"` clips), `--report-project` (project or compound-clip name). Timecode cells: `--timecode-format` (`HH:MM:SS:FF` default, `Frames`, `Feet+Frames`, `HH:MM:SS`). Progress labels follow `ReportBuildPhase.enabledPhases(for:)` (inventory first), then Saving workbook, then Saving PDF when `--create-pdf` is set. Log options: `--log`, `--log-level`, `--quiet`. See `Sources/OpenFCPXMLKitCLI/README.md` and `Documentation/Manual/16-CLI.md`. --- ## 8. Tests -- **Count:** **933** listed in `swift test --list-tests` — **932** in `OpenFCPXMLKitTests` (929 XCTest + 3 Swift Testing `@Test` in `FCPXMLReportRoleExclusionTests`) and **1** in optional `ExcelReportTest` (skips without a local `.fcpxml`/`.fcpxmld` fixture). +- **Count:** **944** listed in `swift test --list-tests` — **942** in `OpenFCPXMLKitTests` (939 XCTest + 3 Swift Testing `@Test` in `FCPXMLReportRoleExclusionTests`) and **2** in optional `ExcelReportTest` (skips without a local `.fcpxml`/`.fcpxmld` fixture). - **Location:** `Tests/OpenFCPXMLKitTests/`; samples in `Tests/FCPXML Samples/FCPXML/` (58 files); optional integration under `Tests/ExcelReportTest/`. - **Utilities:** `FCPXMLTestResources.swift`, `FCPXMLTestUtilities.swift` (path resolution, sample loading; `XCTSkip` when a sample is missing); `FCPXMLReportingReportFixture.swift` / `FCPXMLReportingReportTestSupport.swift` for optional reporting fixtures. -- **Reporting tests:** `FCPXMLCompoundClipReportTests` (standalone compound-clip FCPXML / `allReportTimelineSources()`), `FCPXMLReportTimecodeFormatTests` (DF/NDF, all four formats, format-aware headers, full-report shape), `FCPXMLReportBuildPhaseTests` (inventory-first `enabledPhases` / `onPhaseStarted` order), `FCPXMLRoleInventoryColumnLayoutTests`, `FCPXMLReportColumnExclusionTests` (including suffixed Timeline In headers), `FCPXMLReportExcludeDisabledClipsTests`, `FCPXMLReportExcelExportTests` (workbook cell formatting: Summary title header, black role-duration data, red missing-media paths, inventory/section-sheet colour rules), `FCPXMLReportFormattingTests` (SMPTE / Frames / Feet+Frames / HH:MM:SS formatting and numeric sort guardrails), plus role inventory, section, and related support tests. See **Tests/README.md** §1 for the full file tree. +- **Reporting tests:** `FCPXMLCompoundClipReportTests` (standalone compound-clip FCPXML / `allReportTimelineSources()`), `FCPXMLReportTimecodeFormatTests` (DF/NDF, all four formats, format-aware headers, full-report shape), `FCPXMLReportBuildPhaseTests` (inventory-first `enabledPhases` / `onPhaseStarted` order), `FCPXMLRoleInventoryColumnLayoutTests`, `FCPXMLReportColumnExclusionTests` (including suffixed Timeline In headers), `FCPXMLReportExcludeDisabledClipsTests`, `FCPXMLReportExcelExportTests` (workbook cell formatting), `FCPXMLReportPDFExportTests` (cover, TOC, section parity, pagination, branding), `FCPXMLReportFormattingTests` (SMPTE / Frames / Feet+Frames / HH:MM:SS formatting and numeric sort guardrails), plus role inventory, section, and related support tests. See **Tests/README.md** §1 for the full file tree. - **Coverage:** Unit, integration, and performance tests; sync and async; all supported frame rates and FCPXML versions. See **Tests/README.md** for categories and how to run tests. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index e7051c4..b9a76f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ OpenFCPXMLKit uses **New Features**, **Improvements**, and **Bug Fixes** for eac --- +## [3.0.4](https://github.com/TheAcharya/OpenFCPXMLKit/releases/tag/3.0.4) - 2026-07-13 + +### ✨ New Features + +- **PDF report export:** `FinalCutPro.FCPXML.ReportPDFExport` (`makePDFData(from:)`, `export(_:to:)`) renders a built `Report` to a multi-page `.pdf` via CoreGraphics. All nine workbook sections are supported (Selected Roles Inventory, per-role sheets, Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary, Media Summary) with dynamic column widths, pagination, and truncation for wide tables. +- **PDF presentation:** Cover page with workbook branding (`ReportWorkbookCoverSheet` / `exportBrandingText`), dynamic table of contents (section titles and page numbers), per-page header/footer rules, and section background tinting between header and footer bands. +- **CLI `--create-pdf`:** With `--report`, also writes `{project-or-clip-name}.pdf` beside the `.xlsx` workbook from the same built `Report`. Section flags, `--exclude-column`, `--exclude-role`, `--exclude-disabled-clips`, `--timecode-format`, and `--report-project` apply to both formats. Progress includes a **Saving PDF** step when enabled. + +### 🔧 Improvements + +- **Shared row colours:** Excel and PDF export now use `FCPXMLReportRowColorPolicy` for inventory, section-sheet, Summary, and Media Summary text colours (refactored from `FCPXMLReportWorkbookExporter`). +- **Test suite:** Expanded to **944** tests listed in `swift test --list-tests` (**942** in `OpenFCPXMLKitTests`: 939 XCTest + 3 Swift Testing `@Test`; plus **2** optional `ExcelReportTest`), including `FCPXMLReportPDFExportTests` (cover, TOC, section parity, pagination, branding), `testExportDefaultRoleInventoryPDF` integration coverage, and strengthened `FCPXMLCompoundClipReportTests` / `FCPXMLReportTimecodeFormatTests` regression assertions. +- **CLI help:** `--help` overview and REPORT option strings now describe Excel/PDF report export consistently (`--create-pdf`, `--timecode-format`, `--exclude-column`, `--report-project`). +- **Dependencies:** SwiftExtensions minimum raised to **2.3.2**. +- **Documentation:** Manual chapter **19 — Reporting, Excel & PDF Export**; updated **16 — CLI**, **17 — Examples**, and cross-platform notes; CLI README; `AGENT.md`, `ARCHITECTURE.md` (§2.7 / §8), `.cursorrules`; and `Tests/README.md` / `Tests/ExcelReportTest/README.md`. + +### 🐛 Bug Fixes + +- None in this release. + +--- + ## [3.0.3](https://github.com/TheAcharya/OpenFCPXMLKit/releases/tag/3.0.3) - 2026-07-10 ### ✨ New Features diff --git a/Documentation/Manual.md b/Documentation/Manual.md index dac8e75..dfd9629 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -30,7 +30,7 @@ From the index you can reach all chapters: - **16** — CLI (OpenFCPXMLKit-CLI) - **17** — Examples (workflows and code) - **18** — Cross-Platform & iOS (OFKXML abstraction, Foundation vs AEXML) -- **19** — Reporting & Excel Export (ReportTimecodeFormat, ReportBuildPhase progress, sections, column/disabled-clip exclusion, XLKit workbook and cell formatting) +- **19** — Reporting, Excel & PDF Export (ReportTimecodeFormat, ReportBuildPhase progress, sections, column/disabled-clip exclusion, XLKit workbook, CoreGraphics PDF, shared row colours) --- diff --git a/Documentation/Manual/00-Index.md b/Documentation/Manual/00-Index.md index 61dc6ac..5a1c8f6 100644 --- a/Documentation/Manual/00-Index.md +++ b/Documentation/Manual/00-Index.md @@ -26,7 +26,7 @@ Complete manual and usage guide for **OpenFCPXMLKit**, a Swift 6 framework for F | [16 — CLI](16-CLI.md) | Experimental command-line interface (OpenFCPXMLKit-CLI) | | [17 — Examples](17-Examples.md) | End-to-end workflows and code examples | | [18 — Cross-Platform & iOS](18-Cross-Platform-iOS.md) | XML abstraction layer, OFKXML protocols, Foundation vs AEXML backends, iOS support | -| [19 — Reporting & Excel Export](19-Reporting.md) | Report builder, ReportOptions, ReportTimecodeFormat, ReportBuildPhase progress order, report sections, column/disabled-clip exclusion, RoleDisplayPreference, XLKit workbook export and cell formatting | +| [19 — Reporting, Excel & PDF Export](19-Reporting.md) | Report builder, ReportOptions, ReportTimecodeFormat, ReportBuildPhase progress order, report sections, column/disabled-clip exclusion, RoleDisplayPreference, XLKit workbook export, CoreGraphics PDF export, and shared row colour policy | --- diff --git a/Documentation/Manual/01-Overview.md b/Documentation/Manual/01-Overview.md index 7a0e4ce..72d284a 100644 --- a/Documentation/Manual/01-Overview.md +++ b/Documentation/Manual/01-Overview.md @@ -22,6 +22,7 @@ OpenFCPXMLKit provides a comprehensive API for parsing, creating, and manipulati | **FCPXMLValidator** | Semantic validation (root, resources, ref resolution) | | **FCPXMLDTDValidator** | DTD schema validation for a given FCPXML version | | **FCPXMLExporter** / **FCPXMLBundleExporter** | Export `Timeline` to FCPXML string or `.fcpxmld` bundle | +| **ReportExcelExport** / **ReportPDFExport** | Export a built `Report` to `.xlsx` (XLKit) or `.pdf` (CoreGraphics) | --- diff --git a/Documentation/Manual/10-Extraction-Media.md b/Documentation/Manual/10-Extraction-Media.md index c8bef63..26c268e 100644 --- a/Documentation/Manual/10-Extraction-Media.md +++ b/Documentation/Manual/10-Extraction-Media.md @@ -17,7 +17,7 @@ Extract elements from an FCPXML tree by type or using **presets**. **FinalCutPro - **TitlesExtractionPreset** (`.titles`) — Titles visible on the main timeline (`[ExtractedElement]`) - **EffectsExtractionPreset** (`.effects`) — Semantic clip effects visible on the main timeline (`[ExtractedEffect]`, with `kind`, `name`, `settings`, `isAppleSupplied`) -Call **extract(types:scope:)** on an `FCPXMLElement` (or **fcpExtract(types:scope:)** on `XMLElement`) for `[FinalCutPro.FCPXML.ExtractedElement]`. Call **extract(preset:scope:)** for a preset's result type. APIs are async. The `TitlesExtractionPreset` and `EffectsExtractionPreset` presets are also the basis of the Titles and Effects report sections — see [19 — Reporting & Excel Export](19-Reporting.md). +Call **extract(types:scope:)** on an `FCPXMLElement` (or **fcpExtract(types:scope:)** on `XMLElement`) for `[FinalCutPro.FCPXML.ExtractedElement]`. Call **extract(preset:scope:)** for a preset's result type. APIs are async. The `TitlesExtractionPreset` and `EffectsExtractionPreset` presets are also the basis of the Titles and Effects report sections — see [19 — Reporting, Excel & PDF Export](19-Reporting.md). ```swift let element: FCPXMLElement = // ... e.g. from document @@ -76,4 +76,4 @@ for entry in copyResult.failed { /* error */ } ## Next - [11 — Media Processing](11-Media-Processing.md) — MIME type, asset validation, silence, duration, parallel I/O. -- [19 — Reporting & Excel Export](19-Reporting.md) — build reports from extracted elements and export to `.xlsx`. +- [19 — Reporting, Excel & PDF Export](19-Reporting.md) — build reports from extracted elements and export to `.xlsx` or `.pdf`. diff --git a/Documentation/Manual/14-High-Level-Model.md b/Documentation/Manual/14-High-Level-Model.md index 44f5158..9fba278 100644 --- a/Documentation/Manual/14-High-Level-Model.md +++ b/Documentation/Manual/14-High-Level-Model.md @@ -20,7 +20,7 @@ For quick inspection and high-level access without walking the XML tree, use **F - **allEvents()** — All events in the document - **allProjects()** — All projects in the document - **allTimelines()** — Top-level timelines (sequences, clips, etc.) in document order -- **allReportTimelineSources()** — Timelines suitable for Excel reporting: every project sequence, plus event-level compound clips (`ref-clip` → `media`/`sequence`) when FCP exported a compound clip without a `` +- **allReportTimelineSources()** — Timelines suitable for reporting (Excel/PDF): every project sequence, plus event-level compound clips (`ref-clip` → `media`/`sequence`) when FCP exported a compound clip without a `` ```swift let data = try loader.loadData(from: url) @@ -35,7 +35,7 @@ let version = fcpxml.version Bridging with **FCPXMLVersion** (DTD/validation): use `.fcpxmlVersion` and `.dtdVersion` and `init(from:)` converters where provided. -For building Excel reports from either a project or a standalone compound clip, see [19 — Reporting & Excel Export](19-Reporting.md). +For building Excel or PDF reports from either a project or a standalone compound clip, see [19 — Reporting, Excel & PDF Export](19-Reporting.md). --- diff --git a/Documentation/Manual/16-CLI.md b/Documentation/Manual/16-CLI.md index db24162..41f1d05 100644 --- a/Documentation/Manual/16-CLI.md +++ b/Documentation/Manual/16-CLI.md @@ -29,7 +29,7 @@ Use **one** of: `--check-version`, `--convert-version`, `--validate`, `--media-c ### REPORT -Build an Excel (`.xlsx`) report workbook from FCPXML/FCPXMLD. Works for normal project timelines and for standalone compound-clip exports (event `ref-clip` with no ``). The workbook is written to ``; its file name is derived from the project or compound-clip name. See [19 — Reporting & Excel Export](19-Reporting.md) for the underlying API. +Build an Excel (`.xlsx`) report workbook from FCPXML/FCPXMLD, with optional PDF (`.pdf`) export via `--create-pdf`. Works for normal project timelines and for standalone compound-clip exports (event `ref-clip` with no ``). The workbook is written to ``; its file name is derived from the project or compound-clip name. See [19 — Reporting, Excel & PDF Export](19-Reporting.md) for the underlying API. | Option | Description | |--------|-------------| @@ -43,11 +43,12 @@ Build an Excel (`.xlsx`) report workbook from FCPXML/FCPXMLD. Works for normal p | **--report-speed-change-effects** | Include the Speed Change Effects sheet (with `--report`). | | **--report-summary** | Include the Summary sheet (project metrics and role-duration totals; with `--report`). | | **--report-media-summary** | Include the Media Summary sheet (missing media file paths; with `--report`). | +| **--create-pdf** | Also write a PDF report alongside the Excel workbook (with `--report`). Uses the same built `Report` — sections, column exclusions, timecode format, role/disabled-clip filtering. Writes `{project-or-clip-name}.pdf` to output-dir; prints the PDF path after the `.xlsx` path. | | **--report-project <name>** | Timeline name filter: matches a `` name or a standalone compound-clip / `ref-clip` name when the document has more than one reportable timeline. | | **--exclude-role <role>** | Exclude a role or subrole from the role inventory (repeatable). Excluding a main role also excludes its subroles. | | **--exclude-disabled-clips** | Omit disabled clips (`enabled="0"`) from all timeline-based report sections (with `--report`). | | **--exclude-column <column>** | Exclude a workbook column from every applicable report sheet (repeatable; with `--report`). | -| **--timecode-format <format>** | Timeline time display format for Excel report cells (with `--report`). Values: `HH:MM:SS:FF` (default; SMPTE with frames, `;` before frames for drop-frame), `Frames`, `Feet+Frames`, `HH:MM:SS`. | +| **--timecode-format <format>** | Timeline time display format for report cells in Excel and PDF (with `--report`). Values: `HH:MM:SS:FF` (default; SMPTE with frames, `;` before frames for drop-frame), `Frames`, `Feet+Frames`, `HH:MM:SS`. | When `--report` is used without `--report-full` or section flags, the CLI exports role inventory only. Use `--report-full` for every optional sheet, or set individual `--report-*` section flags for a partial export (role inventory is always included). `--report-full` takes precedence when combined with section flags. @@ -96,7 +97,7 @@ Common values: | `Source File Path` | Removes Source File Path (and Missing Media on Media Summary) | | `Frame Rate` | Removes Frame Rate/Sample Rate (and related summary metric cells) | -Unknown column names are ignored. See [19 — Reporting & Excel Export](19-Reporting.md#column-exclusion) for the full **ReportColumn** list and aliases. +Unknown column names are ignored. See [19 — Reporting, Excel & PDF Export](19-Reporting.md#column-exclusion) for the full **ReportColumn** list and aliases. ```bash OpenFCPXMLKit-CLI --report \ @@ -108,7 +109,7 @@ OpenFCPXMLKit-CLI --report \ #### Timecode display format -`--timecode-format` controls how timeline and source time columns are written in the workbook (and appends a header suffix when not using default SMPTE frames). See [19 — Reporting & Excel Export](19-Reporting.md#timecode-display-format). +`--timecode-format` controls how timeline and source time columns are written in Excel and PDF exports (and appends a header suffix when not using default SMPTE frames). See [19 — Reporting, Excel & PDF Export](19-Reporting.md#timecode-display-format). | Value | Cells | Example headers | |-------|-------|-----------------| @@ -170,6 +171,11 @@ OpenFCPXMLKit-CLI --report --report-full \ --timecode-format Frames \ /path/to/project.fcpxmld /path/to/output-dir +# Excel workbook plus PDF (same report configuration) +OpenFCPXMLKit-CLI --report --report-full --create-pdf \ + --exclude-column Metadata \ + /path/to/project.fcpxmld /path/to/output-dir + # Create a new empty project (e.g. 1920×1080 at 25 fps), write to output-dir; project file name is 1920x1080@25p.fcpxml OpenFCPXMLKit-CLI --create-project --width 1920 --height 1080 --rate 25 /path/to/output-dir OpenFCPXMLKit-CLI --create-project --width 640 --height 480 --rate 29.97 --project-version 1.13 /path/to/output-dir @@ -188,6 +194,6 @@ For source layout, extending the CLI, and regenerating embedded DTDs, see **[Ope ## Next - [17 — Examples](17-Examples.md) — End-to-end workflows and code examples. -- [19 — Reporting & Excel Export](19-Reporting.md) — the reporting API behind `--report`. +- [19 — Reporting, Excel & PDF Export](19-Reporting.md) — the reporting API behind `--report`. [← Manual Index](00-Index.md) diff --git a/Documentation/Manual/17-Examples.md b/Documentation/Manual/17-Examples.md index 6760d04..c500b35 100644 --- a/Documentation/Manual/17-Examples.md +++ b/Documentation/Manual/17-Examples.md @@ -231,7 +231,7 @@ for clip in timeline.clips { --- -## Build an Excel report +## Build a report (Excel and PDF) ```swift import OpenFCPXMLKit @@ -253,14 +253,31 @@ let report = try await fcpxml.buildReport(options: options) { phase in print("Building \(phase.rawValue)…") } -let outputURL = URL(fileURLWithPath: "/path/to/Report.xlsx") -try await FinalCutPro.FCPXML.ReportExcelExport.export(report, to: outputURL) +let xlsxURL = URL(fileURLWithPath: "/path/to/Report.xlsx") +try await FinalCutPro.FCPXML.ReportExcelExport.export(report, to: xlsxURL) + +let pdfURL = URL(fileURLWithPath: "/path/to/Report.pdf") +try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: pdfURL) + print("Wrote \(report.roleInventory?.roleSheets.count ?? 0) role sheet(s)") print("Excluded columns: \(report.excludedColumns)") print("Timecode format: \(report.timecodeFormat.rawValue)") ``` -Equivalent CLI: +Equivalent CLI (Excel + PDF): + +```bash +OpenFCPXMLKit-CLI --report --report-full --create-pdf \ + --exclude-role Effects \ + --exclude-disabled-clips \ + --exclude-column Reel \ + --exclude-column Metadata \ + --exclude-column "Source File Path" \ + --timecode-format Frames \ + /path/to/project.fcpxmld /path/to/output-dir +``` + +Excel-only CLI (omit `--create-pdf`): ```bash OpenFCPXMLKit-CLI --report --report-full \ @@ -273,7 +290,7 @@ OpenFCPXMLKit-CLI --report --report-full \ /path/to/project.fcpxmld /path/to/output-dir ``` -See [19 — Reporting & Excel Export](19-Reporting.md) for the full reporting API. +See [19 — Reporting, Excel & PDF Export](19-Reporting.md) for the full reporting API. --- diff --git a/Documentation/Manual/18-Cross-Platform-iOS.md b/Documentation/Manual/18-Cross-Platform-iOS.md index b499bfa..6570d27 100644 --- a/Documentation/Manual/18-Cross-Platform-iOS.md +++ b/Documentation/Manual/18-Cross-Platform-iOS.md @@ -51,7 +51,7 @@ let version = root?.stringValue(forAttributeNamed: "version") ## Testing -- The `OpenFCPXMLKitTests` suite (**932 tests** listed under that target: 929 XCTest + 3 Swift Testing `@Test`) runs on **macOS** and uses the Foundation backend. `swift test --list-tests` also lists **1** optional `ExcelReportTest` integration test (**933** total). +- The `OpenFCPXMLKitTests` suite (**942 tests** listed under that target: 939 XCTest + 3 Swift Testing `@Test`) runs on **macOS** and uses the Foundation backend. `swift test --list-tests` also lists **2** optional `ExcelReportTest` integration tests (**944** total). - **iOS** is supported for building the library (e.g. iOS Simulator); running the same tests on iOS is not required for CI because they depend on Foundation XML. AEXML parity and structural validation are covered by tests that run on macOS. --- diff --git a/Documentation/Manual/19-Reporting.md b/Documentation/Manual/19-Reporting.md index 4e546cc..a61cbd7 100644 --- a/Documentation/Manual/19-Reporting.md +++ b/Documentation/Manual/19-Reporting.md @@ -1,4 +1,4 @@ -# 19 — Reporting & Excel Export +# 19 — Reporting, Excel & PDF Export [← Manual Index](00-Index.md) @@ -6,20 +6,23 @@ ## Overview -The reporting subsystem builds structured, spreadsheet-style **reports** from a parsed FCPXML document and writes them to an **`.xlsx` workbook** (via XLKit). A report is assembled from independent **sections** (role inventory, markers, keywords, titles & generators, transitions, effects, speed-change effects, summary, media summary), each of which becomes one or more sheets. +The reporting subsystem builds structured **reports** from a parsed FCPXML document and exports them to an **`.xlsx` workbook** (via XLKit) and/or a **`.pdf` document** (via CoreGraphics). A report is assembled from independent **sections** (role inventory, markers, keywords, titles & generators, transitions, effects, speed-change effects, summary, media summary). In Excel, each section becomes one or more worksheet tabs; in PDF, each section becomes one or more content pages with a cover page and dynamic table of contents. Everything lives under **`FinalCutPro.FCPXML`**: - **buildReport(options:scope:onPhaseStarted:)** — convenience entry point on a parsed document. - **ReportBuilder** — assembles a **Report** from a document or a single **Project**. - **ReportOptions** — selects which sections to include, plus project filter, media base URL, role display preference, cover sheet, role exclusions, disabled-clip filtering, column exclusions, and **timecodeFormat**. -- **ReportTimecodeFormat** — how timeline time values appear in workbook cells (`HH:MM:SS:FF`, Frames, Feet+Frames, `HH:MM:SS`). +- **ReportTimecodeFormat** — how timeline time values appear in workbook/PDF cells (`HH:MM:SS:FF`, Frames, Feet+Frames, `HH:MM:SS`). - **Report** — the assembled value type (one optional property per section, plus resolved column exclusions and `timecodeFormat`). - **ReportBuildPhase** — content phases in product / workbook order; use `enabledPhases(for:)` for GUI progress bars. -- **ReportColumn** — logical workbook columns that can be omitted globally at export. +- **ReportColumn** — logical columns that can be omitted globally at export (Excel and PDF). - **ReportExcelExport** — turns a `Report` into an XLKit `Workbook` or writes it to disk. +- **ReportPDFExport** — turns a `Report` into PDF `Data` or writes a multi-page `.pdf` file. -All build APIs are **async**. +All **build** APIs are **async**. PDF export is **synchronous** once a `Report` exists. + +**Configuration parity:** Build the report **once** with `ReportOptions`, then export to Excel, PDF, or both. Section flags, `excludedColumns`, `excludedRoles`, `excludeDisabledClips`, `timecodeFormat`, and `projectName` all apply to both exporters. PDF adds presentation-only features (cover page, table of contents, pagination, truncation) on top of the same `Report` data. --- @@ -34,8 +37,12 @@ let fcpxml = try FinalCutPro.FCPXML(fileContent: data) let report = try await fcpxml.buildReport(options: .full) // Write it to an .xlsx workbook -let outputURL = URL(fileURLWithPath: "/path/to/Report.xlsx") -try await FinalCutPro.FCPXML.ReportExcelExport.export(report, to: outputURL) +let xlsxURL = URL(fileURLWithPath: "/path/to/Report.xlsx") +try await FinalCutPro.FCPXML.ReportExcelExport.export(report, to: xlsxURL) + +// Optionally write the same report to PDF (same sections, columns, and timecode format) +let pdfURL = URL(fileURLWithPath: "/path/to/Report.pdf") +try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: pdfURL) ``` `buildReport` throws **ReportError** (`noProjectsFound`, `projectNotFound(name)`) when no reportable timeline can be resolved. Resolution uses ``FinalCutPro/FCPXML/allReportTimelineSources()``: normal `` sequences first, then event-level compound clips (`ref-clip` → `media`/`sequence`) when the document has no project (FCP “Export XML” of a compound clip). @@ -71,8 +78,8 @@ try await FinalCutPro.FCPXML.ReportExcelExport.export(report, to: outputURL) | `workbookCoverSheet` | `.openFCPXMLKitDefault` | Optional cover sheet; set to `nil` to omit. | | `excludedRoles` | `[]` | Role or subrole names to omit from the role inventory. Excluding a main role also excludes its subroles. | | `excludeDisabledClips` | `false` | When `true`, clips with `enabled="0"` are omitted from every timeline-based section. Default includes disabled clips (matching Final Cut Pro workbook exports). | -| `excludedColumns` | `[]` | Column labels to omit from every applicable workbook sheet at export (see [Column exclusion](#column-exclusion)). | -| `timecodeFormat` | `.smpteFrames` | How timeline time values are formatted in workbook cells (see [Timecode display format](#timecode-display-format)). | +| `excludedColumns` | `[]` | Column labels to omit from every applicable report sheet at export (Excel and PDF; see [Column exclusion](#column-exclusion)). | +| `timecodeFormat` | `.smpteFrames` | How timeline time values are formatted in report cells (Excel and PDF; see [Timecode display format](#timecode-display-format)). | ### Presets @@ -112,7 +119,7 @@ let report = try await fcpxml.buildReport(options: options) ## Timecode display format -**ReportTimecodeFormat** controls how every timeline / source time column is written in workbook cells. Set it on **`ReportOptions.timecodeFormat`**; the value is stored on **`Report.timecodeFormat`** and used by Excel export for both cell values and (non-default) column header suffixes. +**ReportTimecodeFormat** controls how every timeline / source time column is written in report cells. Set it on **`ReportOptions.timecodeFormat`**; the value is stored on **`Report.timecodeFormat`** and used by Excel and PDF export for both cell values and (non-default) column header suffixes. | Case | CLI / `rawValue` | Example cell | Default header suffix | |------|------------------|--------------|------------------------| @@ -150,11 +157,12 @@ let report = try await fcpxml.buildReport(options: options) - `speedChangeEffects: SpeedChangeEffectsReportSection?` - `summary: SummaryReportSection?` - `mediaSummary: MediaSummaryReportSection?` -- `workbookCoverSheet: ReportWorkbookCoverSheet?` +- `workbookCoverSheet: ReportWorkbookCoverSheet?` — optional Excel cover worksheet; branding text is also used on the PDF cover page and running footer - `excludedColumns: Set` — resolved from `ReportOptions.excludedColumns` at build time -- `timecodeFormat: ReportTimecodeFormat` — copied from options; drives Excel headers and cell formatting +- `timecodeFormat: ReportTimecodeFormat` — copied from options; drives Excel and PDF headers and cell formatting +- **`exportBrandingText`** — resolved branding label from `workbookCoverSheet` (or the OpenFCPXMLKit default) for Excel cover cell A1 and PDF cover/footer -A section property is `nil` when that section was not requested. Every section conforms to **ReportSection** and exposes a `defaultSheetName`. Row models expose `columnHeaders` / `columnHeaders(timecodeFormat:)` and `columnValues` in matching order, so sections can be rendered by any backend. +A section property is `nil` when that section was not requested. Every section conforms to **ReportSection** and exposes a `defaultSheetName`. Row models expose `columnHeaders` / `columnHeaders(timecodeFormat:)` and `columnValues` in matching order, so sections can be rendered by either export backend. ### Sections and columns @@ -270,7 +278,7 @@ let report = try await fcpxml.buildReport(options: options) **ReportColumn** identifies logical columns that can be removed from **every applicable workbook sheet** at export time. Set **`excludedColumns`** on `ReportOptions` using header names or common aliases; unknown labels are ignored. -At build time, labels are resolved to `Set` and stored on **`Report.excludedColumns`**. Excel export applies filtering to role inventory sheets, markers, keywords, titles, transitions, effects, speed-change effects, summary (including project metric cells), and media summary. +At build time, labels are resolved to `Set` and stored on **`Report.excludedColumns`**. Excel and PDF export apply the same filtering to role inventory sheets, markers, keywords, titles, transitions, effects, speed-change effects, summary (including project metric cells), and media summary. ### ReportColumn cases @@ -373,7 +381,7 @@ Use the same list in a GUI app for progress total and labels so they match your ```swift let phases = FinalCutPro.FCPXML.ReportBuildPhase.enabledPhases(for: options) -// Use `phases.count` (+ 1 for “Saving workbook” if you export) for progress total. +// Use `phases.count` (+ 1 for “Saving workbook”, + 1 more for “Saving PDF” when exporting both) for progress total. let report = try await fcpxml.buildReport(options: options) { phase in // Fires in product order for each enabled section @@ -422,7 +430,7 @@ Table headers on tabular sheets use a black fill with white text. Data columns a ### Cover sheet -**ReportWorkbookCoverSheet** (`title`, `headerText`) adds an intro sheet. Use **`.openFCPXMLKitDefault`** for the built-in "Created by OpenFCPXMLKit" sheet, a custom value, or `nil` to omit it: +**ReportWorkbookCoverSheet** (`title`, `headerText`) adds an intro worksheet to the Excel workbook. Use **`.openFCPXMLKitDefault`** for the built-in "Created by OpenFCPXMLKit" sheet, a custom value, or `nil` to omit the Excel cover tab. **`headerText`** (via **`Report.exportBrandingText`**) is also shown on the PDF cover page and running footer even when the Excel cover sheet is omitted: ```swift var options = FinalCutPro.FCPXML.ReportOptions.full @@ -434,6 +442,52 @@ options.workbookCoverSheet = FinalCutPro.FCPXML.ReportWorkbookCoverSheet( --- +## PDF export + +**ReportPDFExport** renders a `Report` into a multi-page **A4 landscape** PDF using CoreGraphics: + +```swift +// Build PDF data in memory +let pdfData = try FinalCutPro.FCPXML.ReportPDFExport.makePDFData(from: report) + +// Write directly to disk +try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: pdfURL) +``` + +Throws **ReportPDFExportError** (`couldNotCreateDocument`, `couldNotWriteFile`) on failure. + +### Layout and presentation + +PDF export mirrors Excel **section order** and **sheet names** (via `FCPXMLReportPDFSheetPlan`): + +1. **Cover page** — project name, event name (when present), generated timestamp, experimental-notice info box, and `exportBrandingText`. +2. **Table of contents** — one or more pages listing every included section with start page numbers (built dynamically in a two-pass render so page numbers are accurate). The TOC is not a workbook sheet in Excel; it is PDF-only. +3. **Content pages** — each enabled section, in workbook order, with running header (project name + section title) and footer (branding + page number). + +Per-section presentation: + +- **Per-sheet tint** — pages that belong to the same workbook section share a subtle background tint between the header rule and footer rule. +- **Row colours** — the same rules as Excel (`FCPXMLReportRowColorPolicy`): role inventory category colours, marker-type colours, keywords/titles/effects/transitions inference, red missing-media paths. +- **Tables** — black header row with white text; body uses Menlo. Column widths are measured from content (clamped min/max) and wide tables **paginate horizontally** into column sets (running header shows `Columns 2 of 5` when chunked). +- **Truncation** — cell text that exceeds column width is ellipsized (`…`). For the full untruncated dataset, use the Excel export. +- **Row traceability** — on multi-page or multi-column-set tables, a **Row** (`#`) column is injected and pinned on the left when not already present. + +### Configuration reflected in PDF + +| Setting | PDF behaviour | +|---------|----------------| +| Section include flags | Only non-`nil` sections on `Report` are rendered | +| `excludedColumns` | Same `ReportColumnExclusion` filtering as Excel on every applicable section | +| `timecodeFormat` | Same formatted cell values and suffixed headers as Excel | +| `excludedRoles` | Applied at build time (fewer role-inventory rows/sheets) | +| `excludeDisabledClips` | Applied at build time (fewer rows in all timeline sections) | +| `projectName` | Applied at build time (timeline source and `report.projectName`) | +| `workbookCoverSheet` | `exportBrandingText` on cover and footer (Excel cover tab is separate) | + +Headers such as **Marker Name** or **Type** on the Markers sheet are **not** `ReportColumn` cases; `--exclude-column` cannot remove them in Excel or PDF. + +--- + ## From the CLI The same reports are available through **OpenFCPXMLKit-CLI**: @@ -445,6 +499,7 @@ The same reports are available through **OpenFCPXMLKit-CLI**: | `--report-markers`, `--report-keywords`, … | Individual optional sheets | | `--report-summary` | Summary sheet | | `--report-media-summary` | Media Summary sheet | +| `--create-pdf` | Also write `{project-or-clip-name}.pdf` alongside the `.xlsx` (same report configuration) | | `--report-project ` | Timeline name filter (project or standalone compound-clip name) | | `--exclude-role ` | Omit roles from role inventory (repeatable) | | `--exclude-disabled-clips` | Omit `enabled="0"` clips from all timeline sections | @@ -474,6 +529,11 @@ OpenFCPXMLKit-CLI --report --report-summary --report-media-summary \ --exclude-role Effects \ --exclude-column "Source File Path" \ /path/to/project.fcpxmld /path/to/output-dir + +# Excel + PDF (same sections, column exclusions, and timecode format) +OpenFCPXMLKit-CLI --report --report-full --create-pdf \ + --exclude-column Metadata \ + /path/to/project.fcpxmld /path/to/output-dir ``` --- diff --git a/Documentation/README.md b/Documentation/README.md index 942a4cb..5405d4e 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -30,9 +30,9 @@ The manual is split into **chapters** for easier navigation and maintenance: | [15 — Errors & Utilities](Manual/15-Errors-Utilities.md) | Error types, ErrorHandling, ProgressBar, FCPXMLUID | | [16 — CLI](Manual/16-CLI.md) | Experimental command-line interface (OpenFCPXMLKit-CLI) | | [17 — Examples](Manual/17-Examples.md) | End-to-end workflows and code examples | -| [19 — Reporting & Excel Export](Manual/19-Reporting.md) | Report builder, ReportOptions, ReportTimecodeFormat, ReportBuildPhase progress order, report sections, column/disabled-clip exclusion, RoleDisplayPreference, XLKit workbook export and cell formatting | +| [19 — Reporting, Excel & PDF Export](Manual/19-Reporting.md) | Report builder, ReportOptions, ReportTimecodeFormat, ReportBuildPhase progress order, report sections, column/disabled-clip exclusion, RoleDisplayPreference, XLKit workbook export, CoreGraphics PDF export, and shared row colour policy | -The manual covers the **entire public API** with examples: core operations, async/await, file I/O, validation, timeline creation and manipulation, metadata, media processing, typed models (including Live Drawing, HiddenClipMarker, SmartCollection match rules, Format/Asset 1.13+), version conversion, reporting and Excel export, CLI, and utilities. **Chapter 18** describes the cross-platform XML abstraction (OFKXML protocols, Foundation vs AEXML backends) and **iOS 26+** support. **Chapter 19** covers the reporting subsystem (report builder, sections, role inventory columns, Summary and Media Summary sheets, timecode display formats, inventory-first progress phases, global column and disabled-clip exclusion, XLKit `.xlsx` export, and workbook cell formatting). +The manual covers the **entire public API** with examples: core operations, async/await, file I/O, validation, timeline creation and manipulation, metadata, media processing, typed models (including Live Drawing, HiddenClipMarker, SmartCollection match rules, Format/Asset 1.13+), version conversion, reporting and Excel/PDF export, CLI, and utilities. **Chapter 18** describes the cross-platform XML abstraction (OFKXML protocols, Foundation vs AEXML backends) and **iOS 26+** support. **Chapter 19** covers the reporting subsystem (report builder, sections, role inventory columns, Summary and Media Summary sheets, timecode display formats, inventory-first progress phases, global column and disabled-clip exclusion, XLKit `.xlsx` export, CoreGraphics `.pdf` export with cover/TOC/pagination, and shared workbook row colours). --- diff --git a/Package.swift b/Package.swift index a9098f0..b7d599e 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( // Timecode operations .package(url: "https://github.com/orchetect/swift-timecode", from: "3.1.2"), // Utility extensions - .package(url: "https://github.com/orchetect/swift-extensions", from: "2.2.0"), + .package(url: "https://github.com/orchetect/swift-extensions", from: "2.3.2"), // Explicit logging dependency (Xcode 26 dynamic linking compatibility) .package(url: "https://github.com/apple/swift-log", from: "1.14.0"), // Cross-platform XML parsing (AEXML backend for iOS and other non-macOS platforms) diff --git a/README.md b/README.md index 9b5d18b..a10da27 100755 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A modern Swift 6 framework for working with Final Cut Pro's FCPXML with full concurrency support, SwiftTimecode integration, and [XLKit](https://github.com/TheAcharya/XLKit) integration. -OpenFCPXMLKit provides a comprehensive API for parsing, creating, and manipulating FCPXML files with advanced timecode operations, async/await patterns, and robust error handling. Built with Swift 6.3 and targeting **macOS 26+** and **iOS 26+**, it offers type-safe operations, comprehensive test coverage (**933** tests listed in `swift test --list-tests`: **932** in `OpenFCPXMLKitTests`, plus **1** optional `ExcelReportTest` integration), and seamless integration with SwiftTimecode and XLKit for professional video editing workflows. A cross-platform XML abstraction layer (Foundation on macOS, AEXML on iOS) keeps the library usable on both platforms. +OpenFCPXMLKit provides a comprehensive API for parsing, creating, and manipulating FCPXML files with advanced timecode operations, async/await patterns, and robust error handling. Built with Swift 6.3 and targeting **macOS 26+** and **iOS 26+**, it offers type-safe operations, comprehensive test coverage (**944** tests listed in `swift test --list-tests`: **942** in `OpenFCPXMLKitTests`, plus **2** optional `ExcelReportTest` integration tests), and seamless integration with SwiftTimecode and XLKit for professional video editing workflows. A cross-platform XML abstraction layer (Foundation on macOS, AEXML on iOS) keeps the library usable on both platforms. OpenFCPXMLKit is currently in an experimental stage. It covers most core FCPXML attributes and parameters and provides a solid foundation for parsing, creation, and manipulation, with room for future expansion and additional feature coverage. @@ -49,7 +49,7 @@ This codebase is developed using AI agents. ## Core Features - **FCPXML I/O**: Read, create, modify documents (.fcpxml/.fcpxmld bundles); load via `FCPXMLFileLoader` (sync/async); create FCPXML from scratch with events, projects, resources, and clips. -- **Parsing & Validation**: Parse and validate against bundled DTDs (1.5–1.14); structural/reference and DTD schema validation (full DTD on macOS; cross-platform structural validation on iOS via `FCPXMLStructuralValidator`); **933** tests (**932** in `OpenFCPXMLKitTests` + **1** optional Excel report integration) across 58 FCPXML sample files. +- **Parsing & Validation**: Parse and validate against bundled DTDs (1.5–1.14); structural/reference and DTD schema validation (full DTD on macOS; cross-platform structural validation on iOS via `FCPXMLStructuralValidator`); **944** tests (**942** in `OpenFCPXMLKitTests` + **2** optional Excel/PDF report integration tests) across 58 FCPXML sample files. - **Timecode Operations**: SwiftTimecode integration (`CMTime`, `Timecode`, FCPXML time strings); `FCPXMLTimecode` custom type (arithmetic, frame alignment, conversion); all FCP frame rates (23.976, 24, 25, 29.97, 30, 50, 59.94, 60 fps). - **Typed Models**: Resources, events, clips, projects, adjustments (Crop, Transform, Blend, Stabilization, Volume, Loudness, NoiseReduction, HumReduction, Equalization, MatchEqualization, Transform360, ColorConform, Stereo3D, VoiceIsolation), filters (VideoFilter, AudioFilter, VideoFilterMask with FilterParameter), transitions, multicam (Media.Multicam, Angle, MulticamSource, MCClip), captions/titles (Caption, Title with TextStyle/TextStyleDefinition), smart collections (SmartCollection with match-clip, match-media, match-ratings, match-text, match-usage, match-representation, match-markers, match-analysis-type), collections (CollectionFolder, KeywordCollection). - **Timeline Operations**: Build `Timeline`; create valid projects with custom or preset dimensions and frame rate (via `TimelineFormat`); export to FCPXML/.fcpxmld (including zero-clip/empty timelines); optional event/project UIDs and library location (`FCPXMLUID`); ripple insert, auto lane assignment, clip queries (lane/time range/asset ID), lane range computation; metadata (markers, chapter markers, keywords, ratings, custom metadata, timestamps); secondary storylines; `TimelineFormat` presets and computed properties. @@ -57,8 +57,8 @@ This codebase is developed using AI agents. - **Analysis & Conversion**: Cut detection (edit points, transitions, gaps); typed element filtering (`FCPXMLElementType`); version conversion (strip elements, validate, save as .fcpxml/.fcpxmld); per-version DTD validation; element stripping based on target version DTDs. - **Animation**: KeyframeAnimation, Keyframe with interpolation, FadeIn/FadeOut; integrated with FilterParameter; auxValue support (FCPXML 1.11+). - **Extensions**: CMTime Codable (FCPXML time string encoding/decoding); CollectionFolder and KeywordCollection for organization; Live Drawing (FCPXML 1.11+); HiddenClipMarker (FCPXML 1.13+); Format/Asset 1.13+ (heroEye, heroEyeOverride, mediaReps). -- **Excel Reporting**: Multi-sheet `.xlsx` workbooks from an FCPXML/FCPXMLD via `FinalCutPro.FCPXML.buildReport(options:)` (XLKit-backed). Sheets for Role Inventory (Selected Roles Inventory + per-role), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary (project metrics and role durations), and Media Summary (missing media paths); configurable `ReportTimecodeFormat` (SMPTE frames with DF/NDF, Frames, Feet+Frames, HH:MM:SS; format-aware column headers); role exclusions, global column exclusion, disabled-clip filtering, project-name filtering; inventory-first `ReportBuildPhase` progress callbacks shared by API and CLI. -- **CLI**: `OpenFCPXMLKit-CLI` with `--check-version`, `--convert-version`, `--validate`, `--media-copy`, `--create-project` (new empty FCPXML project with width/height/rate/version), `--report` (Excel report; `--report-full`, per-section flags, `--exclude-role`, `--exclude-column`, `--exclude-disabled-clips`, `--timecode-format`), logging options (see CLI README). +- **Excel & PDF Reporting**: Build a `Report` once from an FCPXML/FCPXMLD via `FinalCutPro.FCPXML.buildReport(options:)`, then export to a multi-sheet `.xlsx` workbook (`ReportExcelExport`, XLKit-backed) and/or a multi-page `.pdf` (`ReportPDFExport`, CoreGraphics). Sheets/sections for Role Inventory (Selected Roles Inventory + per-role), Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary (project metrics and role durations), and Media Summary (missing media paths); configurable `ReportTimecodeFormat` (SMPTE frames with DF/NDF, Frames, Feet+Frames, HH:MM:SS; format-aware column headers); role exclusions, global column exclusion, disabled-clip filtering, project-name filtering; inventory-first `ReportBuildPhase` progress callbacks shared by API and CLI. PDF adds a cover page, dynamic table of contents, pagination, and shared row-colour rules with Excel. +- **CLI**: `OpenFCPXMLKit-CLI` with `--check-version`, `--convert-version`, `--validate`, `--media-copy`, `--create-project` (new empty FCPXML project with width/height/rate/version), `--report` (Excel report; `--report-full`, per-section flags, `--exclude-role`, `--exclude-column`, `--exclude-disabled-clips`, `--timecode-format`, `--create-pdf`), logging options (see CLI README). - **Architecture**: Protocol-oriented, dependency-injected; sync/async APIs; Swift 6 concurrency-safe design; comprehensive test suite with file-based and logic tests. ## Requirements @@ -169,7 +169,7 @@ Once the build has finished, the `OpenFCPXMLKit-CLI` executable will be located ```plain $ OpenFCPXMLKit-CLI --help -OVERVIEW: Experimental tool to read, validate and generate Excel report from Final Cut Pro FCPXML/FCPXMLD. +OVERVIEW: Experimental tool to read, validate and generate Excel/PDF reports from Final Cut Pro FCPXML/FCPXMLD. https://github.com/TheAcharya/OpenFCPXMLKit @@ -184,24 +184,29 @@ GENERAL: --convert-version Convert FCPXML to the given version (e.g. 1.10, 1.14) and write to output-dir. --extension-type - Output format for --convert-version: fcpxmld (bundle) or fcpxml (single file). Default: fcpxmld. For target versions 1.5–1.9, - .fcpxml is used regardless. (values: fcpxml, fcpxmld; default: fcpxmld) + Output format for --convert-version: fcpxmld (bundle) or fcpxml (single file). Default: + fcpxmld. For target versions 1.5–1.9, .fcpxml is used regardless. (values: fcpxml, fcpxmld; + default: fcpxmld) --validate Perform robust check and validation of FCPXML/FCPXMLD (semantic + DTD). TIMELINE: - --create-project Create a new empty FCPXML project (requires --width, --height, --rate, and positional). + --create-project Create a new empty FCPXML project (requires --width, --height, --rate, and + positional). --width Project width in pixels (used with --create-project). --height Project height in pixels (used with --create-project). --rate Frame rate (e.g. 24, 25, 29.97) (used with --create-project). --project-version - FCPXML version for the new project (e.g. 1.10, 1.14). Default: 1.14. (used with --create-project). + FCPXML version for the new project (e.g. 1.10, 1.14). Default: 1.14. (used with + --create-project). EXTRACTION: --media-copy Scan FCPXML/FCPXMLD and copy all referenced media files to output-dir. REPORT: - --report Build an Excel report workbook from FCPXML (role inventory only; use --report-full for all sheets). - --report-full Include every optional report sheet (with --report). Default --report exports role inventory only. + --report Build an Excel report workbook from FCPXML (role inventory only; use --report-full for all + sheets). + --report-full Include every optional report sheet (with --report). Default --report exports role inventory + only. --report-markers Include the Markers sheet (with --report). --report-keywords Include the Keywords sheet (with --report). --report-titles-generators @@ -212,23 +217,30 @@ REPORT: Include the Speed Change Effects sheet (with --report). --report-summary Include the Summary sheet (project metrics and role-duration totals; with --report). --report-media-summary Include the Media Summary sheet (missing media file paths; with --report). + --create-pdf Also write a PDF report alongside the Excel workbook (with --report). Includes the same + workbook sections, column exclusions, and timecode formatting when present. --report-project - Project name filter when the FCPXML contains multiple projects. + Timeline name filter: matches a name or a standalone compound-clip / ref-clip name + when the document has more than one reportable timeline. --exclude-role - Exclude a role or subrole from role inventory (repeatable). Excluding a main role also excludes its subroles. + Exclude a role or subrole from role inventory (repeatable). Excluding a main role also + excludes its subroles. --exclude-disabled-clips Omit disabled clips (enabled="0") from all report sections (with --report). --exclude-column - Exclude a workbook column from every applicable report sheet (repeatable; with --report). Case-insensitive names include Row - Numbers, Role Subrole, Clip Name, Frame Rate, Reel, Metadata (role inventory dynamic metadata keys), and other shared column - headers. Columns are removed wherever the sheet uses a matching header. + Exclude a report column from every applicable Excel/PDF sheet (repeatable; with --report). + Case-insensitive names include Row Numbers, Role Subrole, Clip Name, Frame Rate, Reel, + Metadata (role inventory dynamic metadata keys), and other shared column headers. Columns + are removed wherever the sheet uses a matching header. --timecode-format - Timeline time display format for Excel report cells (with --report). Values: HH:MM:SS:FF, Frames, Feet+Frames, HH:MM:SS. - Default: HH:MM:SS:FF (SMPTE with frames; semicolon before frames for drop-frame). (default: HH:MM:SS:FF) + Timeline time display format for Excel and PDF report cells (with --report). Values: + HH:MM:SS:FF, Frames, Feet+Frames, HH:MM:SS. Default: HH:MM:SS:FF (SMPTE with frames; + semicolon before frames for drop-frame). (default: HH:MM:SS:FF) LOG: --log Log file path. - --log-level Log level. (values: trace, debug, info, notice, warning, error, critical; default: info) (default: info) + --log-level Log level. (values: trace, debug, info, notice, warning, error, critical; default: info) + (default: info) --quiet Disable log. OPTIONS: @@ -241,8 +253,8 @@ OPTIONS: Complete manual, usage guide, and examples are in the [Documentation](Documentation/) folder: - [Manual](Documentation/Manual.md) — Full user manual: loading, modular operations, time conversions, logging, error handling, async/await, task groups, extensions, validation, version conversion, and step-by-step examples. -- [Reporting & Excel Export](Documentation/Manual/19-Reporting.md) — Build Excel workbooks: `buildReport`, `ReportBuilder`, `ReportOptions`, `ReportTimecodeFormat`, `ReportBuildPhase` progress, `ReportColumn` exclusion, disabled-clip filtering, Summary and Media Summary sheets, `ReportExcelExport`, and CLI integration. -- [CLI](Sources/OpenFCPXMLKitCLI/README.md) — Experimental command-line interface: `--check-version`, `--convert-version`, `--validate`, `--media-copy`, `--create-project`, `--report` / `--timecode-format`, building and extending. +- [Reporting, Excel & PDF Export](Documentation/Manual/19-Reporting.md) — Build reports: `buildReport`, `ReportBuilder`, `ReportOptions`, `ReportTimecodeFormat`, `ReportBuildPhase` progress, `ReportColumn` exclusion, disabled-clip filtering, Summary and Media Summary sheets, `ReportExcelExport`, `ReportPDFExport`, and CLI integration (`--create-pdf`). +- [CLI](Sources/OpenFCPXMLKitCLI/README.md) — Experimental command-line interface: `--check-version`, `--convert-version`, `--validate`, `--media-copy`, `--create-project`, `--report` / `--timecode-format` / `--create-pdf`, building and extending. ## FCPXML Version Support @@ -255,14 +267,14 @@ OpenFCPXMLKit supports FCPXML versions 1.5 through 1.14. All DTDs for these vers - Protocol-oriented and dependency-injected: core behaviour (parsing, timecode, document ops, error handling) is behind protocols with default implementations you can replace. Inject when creating FCPXMLService or FCPXMLUtility or when using modular extension overloads. - Extension APIs that can't take a parameter use a single shared instance (FCPXMLUtility.defaultForExtensions) for consistency and concurrency safety; use overloads with a `using:` parameter for custom services. -- Built with Swift 6 and strict concurrency; Sendable where possible, no unsafe code. Key dependencies: [SwiftTimecode](https://github.com/orchetect/swift-timecode), [SwiftExtensions](https://github.com/orchetect/swift-extensions), [swift-log](https://github.com/apple/swift-log), [AEXML](https://github.com/tadija/AEXML) (iOS XML backend), [XLKit](https://github.com/TheAcharya/XLKit) (Excel report export), and [swift-argument-parser](https://github.com/apple/swift-argument-parser) (CLI only). Minimum versions are defined in `Package.swift`. +- Built with Swift 6 and strict concurrency; Sendable where possible, no unsafe code. Key dependencies: [SwiftTimecode](https://github.com/orchetect/swift-timecode), [SwiftExtensions](https://github.com/orchetect/swift-extensions), [swift-log](https://github.com/apple/swift-log), [AEXML](https://github.com/tadija/AEXML) (iOS XML backend), [XLKit](https://github.com/TheAcharya/XLKit) (Excel report export), CoreGraphics (PDF report export), and [swift-argument-parser](https://github.com/apple/swift-argument-parser) (CLI only). Minimum versions are defined in `Package.swift`. ## Architecture Overview - Protocols define parsing, timecode conversion, document operations, error handling, MIME type detection, asset validation, silence detection, asset duration measurement, and parallel file I/O; each has a default implementation you can swap. FCPXMLService (and FCPXMLUtility) composes these and exposes sync and async APIs. ModularUtilities provides createService, processFCPXML, validateDocument, convertTimecodes, and similar helpers. - FCPXMLFileLoader handles .fcpxml and .fcpxmld (including bundle Info.fcpxml). FCPXMLValidator and FCPXMLDTDValidator handle structural and schema validation (full DTD on macOS; FCPXMLStructuralValidator on iOS when DTD is unavailable); DTDs for 1.5–1.14 are bundled. - A cross-platform XML layer (`Sources/OpenFCPXMLKit/XML/`) provides protocol types (OFKXMLNode, OFKXMLElement, OFKXMLDocument, OFKXMLFactory) with Foundation and AEXML backends. Extensions on CMTime and the XML protocol types offer convenience APIs; use modular overloads with an explicit dependency to inject your own. Error types are explicit (FCPXMLError, FCPXMLLoadError, export and validation errors); you can inject a custom error handler. -- The engine is layered bottom-up — `XML → Parsing → Model → Extraction → Reporting` — so the CLI, extraction presets, timeline tools, and reports share one foundation. The `Reporting/` layer (with `Reporting/Excel/` for XLKit workbook export) maps already-extracted facts into report rows and sheets; it owns presentation only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full codebase map and layer boundaries. +- The engine is layered bottom-up — `XML → Parsing → Model → Extraction → Reporting` — so the CLI, extraction presets, timeline tools, and reports share one foundation. The `Reporting/` layer (with `Reporting/Excel/` for XLKit workbook export and `Reporting/PDF/` for CoreGraphics PDF export) maps already-extracted facts into report rows and sheets/pages; it owns presentation only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full codebase map and layer boundaries. See AGENT.md for a detailed breakdown for AI agents and contributors. diff --git a/Sources/OpenFCPXMLKit/Reporting/Excel/FCPXMLReportWorkbookExporter.swift b/Sources/OpenFCPXMLKit/Reporting/Excel/FCPXMLReportWorkbookExporter.swift index 72dcc85..3c655f9 100644 --- a/Sources/OpenFCPXMLKit/Reporting/Excel/FCPXMLReportWorkbookExporter.swift +++ b/Sources/OpenFCPXMLKit/Reporting/Excel/FCPXMLReportWorkbookExporter.swift @@ -19,125 +19,25 @@ enum FCPXMLReportWorkbookExporter { return format } + /// Workbook sheet context for row text colour when Category is unavailable. + private typealias RoleRowColorContext = FCPXMLReportRowColorPolicy.Context + private static func roleFontFormat( for roleSubrole: String, categoryLabel: String? = nil, context: RoleRowColorContext = .roleInventory ) -> CellFormat? { - let bucket = roleColorBucket( + let bucket = FCPXMLReportRowColorPolicy.bucket( for: roleSubrole, categoryLabel: categoryLabel, context: context ) - guard let fontColor = bucket.fontColorHex else { - return nil - } var format = CellFormat() - format.fontColor = fontColor + format.fontColor = bucket.fontColorHex return format } - /// Workbook sheet context for row text colour when Category is unavailable. - private enum RoleRowColorContext { - case roleInventory - case keywords - case titlesAndGenerators - case effects - case speedChangeEffects - case transitions - } - - private static func roleColorBucket( - for roleSubrole: String, - categoryLabel: String? = nil, - context: RoleRowColorContext = .roleInventory - ) -> RoleColorBucket { - switch context { - case .keywords: - return .videoOrSRT - case .transitions: - return .gap - case .effects, .speedChangeEffects: - return roleBucketFromRoleName(roleSubrole, titlesAsVideo: true) - case .titlesAndGenerators: - return roleBucketFromRoleName(roleSubrole, titlesAsVideo: false) - case .roleInventory: - if let categoryLabel, - let category = FinalCutPro.FCPXML.ReportClipCategory.matchingWorkbookLabel(categoryLabel) - { - if category == .primaryGap { - return .gap - } - if category.isTitleCategory { - return .titles - } - if category.isVideoCategory || category.isCaptionCategory { - return .videoOrSRT - } - if category.isAudioCategory { - return .audio - } - } - - return roleBucketFromRoleName(roleSubrole, titlesAsVideo: false) - } - } - - private static func roleBucketFromRoleName( - _ roleSubrole: String, - titlesAsVideo: Bool - ) -> RoleColorBucket { - if roleSubrole.localizedCaseInsensitiveContains("gap") { - return .gap - } - - let mainRole = mainRoleName(in: roleSubrole).lowercased() - - if mainRole.isEmpty { - return .videoOrSRT - } - - if mainRole == "titles" || mainRole == "title" { - return titlesAsVideo ? .videoOrSRT : .titles - } - - if ["video", "srt", "vfx"].contains(mainRole) || mainRole.hasPrefix("vfx") { - return .videoOrSRT - } - - if ["dialogue", "effects", "music", "atmos", "score komponist", "sound mix"].contains(mainRole) { - return .audio - } - - if roleSubrole.localizedCaseInsensitiveContains("vfx") - || roleSubrole.localizedCaseInsensitiveContains("scanline") - { - return .videoOrSRT - } - - if roleSubrole.localizedCaseInsensitiveContains("title") { - return titlesAsVideo ? .videoOrSRT : .titles - } - - return .videoOrSRT - } - - private static func mainRoleName(in roleSubrole: String) -> String { - let trimmed = roleSubrole.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - - if let separatorIndex = trimmed.firstIndex(where: { ["▸", "•", "·"].contains(String($0)) }) { - return String(trimmed[.. Workbook { let workbook = Workbook() @@ -343,16 +243,7 @@ enum FCPXMLReportWorkbookExporter { private static func markerFontColorHex( for markerType: FinalCutPro.FCPXML.MarkerReportType ) -> String { - switch markerType { - case .standard: - return RoleColorBucket.videoOrSRT.fontColorHex! - case .incompleteToDo: - return "#FF0000" - case .completedToDo: - return RoleColorBucket.audio.fontColorHex! - case .chapter: - return "#FF8800" - } + FCPXMLReportRowColorPolicy.markerFontColorHex(for: markerType) } private static func appendTabularSection( @@ -364,8 +255,8 @@ enum FCPXMLReportWorkbookExporter { ) { let sheet = workbook.addSheet(name: sanitizeSheetName(sheetName)) setTableHeaderRow(sheet, row: 1, strings: headers) - let roleColumnIndex = headers.firstIndex(of: roleSubroleColumnHeader).map { $0 + 1 } - let categoryColumnIndex = headers.firstIndex(of: "Category").map { $0 + 1 } + let roleColumnIndex = headers.firstIndex(of: FCPXMLReportRowColorPolicy.roleSubroleColumnHeader).map { $0 + 1 } + let categoryColumnIndex = headers.firstIndex(of: FCPXMLReportRowColorPolicy.categoryColumnHeader).map { $0 + 1 } for (index, values) in rows.enumerated() { let rowIndex = index + 2 sheet.setRow(rowIndex, strings: values) @@ -403,13 +294,11 @@ enum FCPXMLReportWorkbookExporter { categoryValue = values[categoryColumnIndex - 1] } - guard let roleFormat = roleFontFormat( + let roleFormat = roleFontFormat( for: roleValue, categoryLabel: categoryValue, context: colorContext - ) else { - return - } + ) for (columnIndex, value) in values.enumerated() { let coordinate = CellCoordinate(row: row, column: columnIndex + 1).excelAddress @@ -613,7 +502,6 @@ enum FCPXMLReportWorkbookExporter { return String(string.prefix(31)) } - private static let roleSubroleColumnHeader = "Role ▸ Subrole" private static let percentOfTotalColumnHeader = "% of Total" /// Red text for missing media file paths on the Media Summary sheet. @@ -633,25 +521,3 @@ enum FCPXMLReportWorkbookExporter { return format } } - -private extension FCPXMLReportWorkbookExporter { - enum RoleColorBucket { - case videoOrSRT - case titles - case audio - case gap - - var fontColorHex: String? { - switch self { - case .videoOrSRT: - return "#0066FF" - case .titles: - return "#9933FF" - case .audio: - return "#00AA44" - case .gap: - return "#808080" - } - } - } -} diff --git a/Sources/OpenFCPXMLKit/Reporting/FCPXMLReport.swift b/Sources/OpenFCPXMLKit/Reporting/FCPXMLReport.swift index a74abbe..7103099 100644 --- a/Sources/OpenFCPXMLKit/Reporting/FCPXMLReport.swift +++ b/Sources/OpenFCPXMLKit/Reporting/FCPXMLReport.swift @@ -11,12 +11,12 @@ import Foundation extension FinalCutPro.FCPXML { - /// Workbook cover sheet settings used by Excel export. + /// Workbook cover sheet settings used by Excel export and PDF cover/footer branding. public struct ReportWorkbookCoverSheet: Sendable, Equatable { /// Worksheet tab title. public var title: String - /// Header text written into cell `A1`. + /// Header text written into Excel cell `A1` and used for PDF cover/footer branding. public var headerText: String public init( @@ -29,11 +29,14 @@ extension FinalCutPro.FCPXML { /// Default OpenFCPXMLKit-branded cover sheet. public static let openFCPXMLKitDefault = ReportWorkbookCoverSheet() + + /// Branding label shared by Excel cover cell A1 and PDF cover/footer. + public var brandingText: String { headerText } } /// Structured report data extracted from an FCPXML project. /// - /// Export to Excel via ``ReportExcelExport``. + /// Export to Excel via ``ReportExcelExport`` or PDF via ``ReportPDFExport``. public struct Report: Sendable, Equatable { /// Name of the project the report was built from. public var projectName: String @@ -68,7 +71,7 @@ extension FinalCutPro.FCPXML { /// Role-based clip inventory (Selected Roles and per-role sheets). public var roleInventory: RoleInventoryReportSection? - /// Optional first workbook sheet prepended by Excel export. + /// Optional cover sheet prepended by Excel export; branding text is also used on PDF cover/footer. public var workbookCoverSheet: ReportWorkbookCoverSheet? /// Columns omitted from every applicable workbook sheet at export. @@ -108,5 +111,12 @@ extension FinalCutPro.FCPXML { self.excludedColumns = excludedColumns self.timecodeFormat = timecodeFormat } + + /// Resolved export branding for Excel cover and PDF cover/footer. + /// + /// Uses ``workbookCoverSheet`` when set; otherwise ``ReportWorkbookCoverSheet/openFCPXMLKitDefault``. + public var exportBrandingText: String { + workbookCoverSheet?.brandingText ?? ReportWorkbookCoverSheet.openFCPXMLKitDefault.brandingText + } } } diff --git a/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFCanvas.swift b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFCanvas.swift new file mode 100644 index 0000000..69dc970 --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFCanvas.swift @@ -0,0 +1,681 @@ +// +// FCPXMLReportPDFCanvas.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// CoreGraphics PDF page drawing helpers for report export. +// + +import CoreGraphics +import CoreText +import Foundation + +enum FCPXMLReportPDFCanvas { + final class Builder { + private let context: CGContext + private var projectName = "" + private var eventName: String? + private var exportBrandingText = FinalCutPro.FCPXML.ReportWorkbookCoverSheet.openFCPXMLKitDefault.brandingText + private var pageNumber = 0 + private var cursorY = FCPXMLReportPDFStyle.contentTop + private var hasOpenPage = false + private var runningPageTitle = "" + private var runningContentHeading: String? + private var runningColumnPart: Int? + private var runningColumnPartCount: Int? + private var runningSheetColorIndex = 0 + private var runningSheetContentColor = FCPXMLReportPDFStyle.pageBackgroundColor + private var runningSheetAccentColor = FCPXMLReportPDFStyle.pageBackgroundColor + private var sectionStartRecorder: ((String, Int) -> Void)? + + init(context: CGContext) { + self.context = context + } + + func configureDocument( + projectName: String, + eventName: String?, + exportBrandingText: String, + sectionStartRecorder: ((String, Int) -> Void)? = nil + ) { + self.projectName = projectName + self.eventName = eventName + self.exportBrandingText = exportBrandingText + self.sectionStartRecorder = sectionStartRecorder + } + + func reserveBlankPages(_ count: Int) { + guard count > 0 else { return } + for _ in 0.. 0 } + guard !validEntries.isEmpty else { return } + + let pageColumnWidth: CGFloat = 44 + let indexColumnWidth: CGFloat = 24 + let sheetColumnWidth = FCPXMLReportPDFStyle.contentWidth - pageColumnWidth - indexColumnWidth + let columnWidths = [indexColumnWidth, sheetColumnWidth, pageColumnWidth] + let headers = ["#", "Sheet", "Page"] + let rowsPerPage = FCPXMLReportPDFSheetPlan.tocRowsPerPage() + var entryOffset = 0 + + while entryOffset < validEntries.count { + let pageEntries = Array(validEntries.dropFirst(entryOffset).prefix(rowsPerPage)) + + runningPageTitle = "Table of Contents" + beginPage(drawRunningBands: true, sheetColorIndex: nil) + cursorY = FCPXMLReportPDFStyle.contentTop + + if entryOffset == 0 { + drawSectionTitle("Table of Contents") + } else { + drawContinuationTitle("Table of Contents") + } + + drawTableHeaderRow(headers: headers, columnWidths: columnWidths) + + for (offset, entry) in pageEntries.enumerated() { + drawTOCTableDataRow( + index: entryOffset + offset + 1, + title: entry.title, + startPage: entry.startPage, + columnWidths: columnWidths + ) + } + + endPage() + entryOffset += pageEntries.count + } + } + + func drawCoverPage(projectName: String, eventName: String?) { + beginPage(drawRunningBands: false) + + drawText( + projectName, + x: FCPXMLReportPDFStyle.margin, + y: FCPXMLReportPDFStyle.margin + FCPXMLReportPDFStyle.coverTitleFontSize, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: FCPXMLReportPDFStyle.coverTitleFontSize, + color: FCPXMLReportPDFStyle.textColor + ) + + var y = FCPXMLReportPDFStyle.margin + FCPXMLReportPDFStyle.coverTitleFontSize + 20 + + if let eventName, !eventName.isEmpty { + drawText( + eventName, + x: FCPXMLReportPDFStyle.margin, + y: y + FCPXMLReportPDFStyle.coverSubtitleFontSize, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: FCPXMLReportPDFStyle.coverSubtitleFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + y += FCPXMLReportPDFStyle.coverSubtitleFontSize + 14 + } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + + drawText( + "Generated on \(formatter.string(from: Date()))", + x: FCPXMLReportPDFStyle.margin, + y: y + FCPXMLReportPDFStyle.coverSubtitleFontSize, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: FCPXMLReportPDFStyle.coverSubtitleFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + y += FCPXMLReportPDFStyle.coverSubtitleFontSize + 14 + + drawText( + exportBrandingText, + x: FCPXMLReportPDFStyle.margin, + y: y + FCPXMLReportPDFStyle.coverSubtitleFontSize, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: FCPXMLReportPDFStyle.coverSubtitleFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + + drawCoverInformationBox() + + endPage() + } + + private func drawCoverInformationBox() { + let boxX = FCPXMLReportPDFStyle.margin + let boxWidth = FCPXMLReportPDFStyle.contentWidth + let padding = FCPXMLReportPDFStyle.coverInfoBoxPadding + let innerWidth = boxWidth - (padding * 2) + let titleFontSize = FCPXMLReportPDFStyle.coverInfoTitleFontSize + let bodyFontSize = FCPXMLReportPDFStyle.coverInfoBodyFontSize + let lineSpacing = FCPXMLReportPDFStyle.coverInfoLineSpacing + let paragraphSpacing = FCPXMLReportPDFStyle.coverInfoParagraphSpacing + + var bodyLineCount = 0 + for paragraph in FCPXMLReportPDFCoverNotes.paragraphs { + bodyLineCount += FCPXMLReportPDFTableLayout.wrappedLines( + paragraph, + maxWidth: innerWidth, + fontSize: bodyFontSize + ).count + } + + let boxHeight = padding * 2 + + titleFontSize + + paragraphSpacing + + CGFloat(bodyLineCount) * (bodyFontSize + lineSpacing) + + CGFloat(max(0, FCPXMLReportPDFCoverNotes.paragraphs.count - 1)) * paragraphSpacing + + let boxY = FCPXMLReportPDFStyle.pageSize.height + - FCPXMLReportPDFStyle.margin + - boxHeight + + let boxRect = CGRect(x: boxX, y: boxY, width: boxWidth, height: boxHeight) + context.setFillColor(FCPXMLReportPDFStyle.coverInfoBoxBackgroundColor) + context.fill(boxRect) + context.setStrokeColor(FCPXMLReportPDFStyle.coverInfoBoxBorderColor) + context.setLineWidth(0.75) + context.stroke(boxRect) + + var cursor = boxY + padding + titleFontSize + + drawText( + FCPXMLReportPDFCoverNotes.title, + x: boxX + padding, + y: cursor, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: titleFontSize, + color: FCPXMLReportPDFStyle.textColor + ) + cursor += titleFontSize + paragraphSpacing + + for (index, paragraph) in FCPXMLReportPDFCoverNotes.paragraphs.enumerated() { + let lines = FCPXMLReportPDFTableLayout.wrappedLines( + paragraph, + maxWidth: innerWidth, + fontSize: bodyFontSize + ) + + for line in lines { + drawText( + line, + x: boxX + padding, + y: cursor, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: bodyFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + cursor += bodyFontSize + lineSpacing + } + + if index < FCPXMLReportPDFCoverNotes.paragraphs.count - 1 { + cursor += paragraphSpacing + } + } + } + + func beginContentPage( + pageTitle: String, + sheetColorIndex: Int, + contentHeading: String? = nil, + columnPart: Int? = nil, + columnPartCount: Int? = nil, + repeatOnContinuation: Bool = false, + recordsSectionStart: Bool = false + ) { + if hasOpenPage { + endPage() + } + + runningPageTitle = pageTitle + runningContentHeading = contentHeading + runningColumnPart = columnPart + runningColumnPartCount = columnPartCount + applySheetColors(sheetColorIndex: sheetColorIndex) + + beginPage(drawRunningBands: true, sheetColorIndex: sheetColorIndex) + + if recordsSectionStart, let sectionStartRecorder { + sectionStartRecorder(pageTitle, pageNumber) + } + + cursorY = FCPXMLReportPDFStyle.contentTop + + if !repeatOnContinuation { + let heading = contentHeading ?? pageTitle + drawSectionTitle(heading) + } else { + let heading = contentHeading ?? pageTitle + drawContinuationTitle(heading) + } + } + + func endContentPage() { + endPage() + } + + func drawSectionTitle(_ title: String) { + drawText( + title, + x: FCPXMLReportPDFStyle.margin, + y: cursorY + FCPXMLReportPDFStyle.sectionTitleFontSize, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: FCPXMLReportPDFStyle.sectionTitleFontSize, + color: FCPXMLReportPDFStyle.textColor + ) + cursorY += FCPXMLReportPDFStyle.sectionTitleFontSize + 10 + } + + private func drawContinuationTitle(_ title: String) { + drawText( + "\(title) (continued)", + x: FCPXMLReportPDFStyle.margin, + y: cursorY + FCPXMLReportPDFStyle.subsectionTitleFontSize, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: FCPXMLReportPDFStyle.subsectionTitleFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + cursorY += FCPXMLReportPDFStyle.subsectionTitleFontSize + 8 + } + + func drawSubsectionTitle(_ title: String) { + drawText( + title, + x: FCPXMLReportPDFStyle.margin, + y: cursorY + FCPXMLReportPDFStyle.subsectionTitleFontSize, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: FCPXMLReportPDFStyle.subsectionTitleFontSize, + color: FCPXMLReportPDFStyle.textColor + ) + cursorY += FCPXMLReportPDFStyle.subsectionTitleFontSize + 8 + } + + func drawBodyLine( + _ text: String, + color: CGColor = FCPXMLReportPDFStyle.textColor, + fontSize: CGFloat = FCPXMLReportPDFStyle.bodyFontSize + ) { + ensureVerticalSpace(fontSize + 8) + drawText( + text, + x: FCPXMLReportPDFStyle.margin, + y: cursorY + fontSize, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: fontSize, + color: color + ) + cursorY += fontSize + 8 + } + + func drawSummaryProjectTitle(_ title: String) { + ensureVerticalSpace(FCPXMLReportPDFStyle.summaryTitleFontSize + 10) + drawText( + title, + x: FCPXMLReportPDFStyle.margin, + y: cursorY + FCPXMLReportPDFStyle.summaryTitleFontSize, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: FCPXMLReportPDFStyle.summaryTitleFontSize, + color: FCPXMLReportPDFStyle.textColor + ) + cursorY += FCPXMLReportPDFStyle.summaryTitleFontSize + 10 + } + + func drawTable( + context tableContext: FCPXMLReportPDFTableRenderer.TableDrawContext, + headers: [String], + rows: [[String]], + rowTextColor: CGColor = FCPXMLReportPDFStyle.textColor, + rowTextColorForRow: ((Int, [String]) -> CGColor)? = nil + ) { + FCPXMLReportPDFTableRenderer.drawTable( + on: self, + context: tableContext, + headers: headers, + rows: rows, + rowTextColor: rowTextColor, + rowTextColorForRow: rowTextColorForRow + ) + cursorY += FCPXMLReportPDFStyle.tableSpacing + } + + func drawTableHeaderRow(headers: [String], columnWidths: [CGFloat]) { + ensureVerticalSpace(FCPXMLReportPDFStyle.headerRowHeight) + + let originX = FCPXMLReportPDFStyle.margin + let tableWidth = columnWidths.reduce(0, +) + let rowRect = CGRect( + x: originX, + y: cursorY, + width: tableWidth, + height: FCPXMLReportPDFStyle.headerRowHeight + ) + + context.setFillColor(FCPXMLReportPDFStyle.headerBackgroundColor) + context.fill(rowRect) + + var x = originX + for (index, header) in headers.enumerated() { + let width = columnWidths[index] + let text = FCPXMLReportPDFTableLayout.truncated( + header, + maxWidth: width, + bold: true, + fontSize: FCPXMLReportPDFStyle.headerFontSize + ) + drawText( + text, + x: x + FCPXMLReportPDFStyle.cellPadding, + y: cursorY + FCPXMLReportPDFStyle.headerFontSize + 4, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: FCPXMLReportPDFStyle.headerFontSize, + color: FCPXMLReportPDFStyle.headerTextColor + ) + x += width + } + + cursorY += FCPXMLReportPDFStyle.headerRowHeight + } + + func drawTableDataRow( + values: [String], + columnWidths: [CGFloat], + textColor: CGColor = FCPXMLReportPDFStyle.textColor + ) { + ensureVerticalSpace(FCPXMLReportPDFStyle.rowHeight) + + var x = FCPXMLReportPDFStyle.margin + let tableWidth = columnWidths.reduce(0, +) + + for (index, value) in values.enumerated() { + let width = columnWidths[index] + let text = FCPXMLReportPDFTableLayout.truncated( + value, + maxWidth: width, + fontSize: FCPXMLReportPDFStyle.bodyFontSize + ) + drawText( + text, + x: x + FCPXMLReportPDFStyle.cellPadding, + y: cursorY + FCPXMLReportPDFStyle.bodyFontSize + 3, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: FCPXMLReportPDFStyle.bodyFontSize, + color: textColor + ) + x += width + } + + context.setStrokeColor(FCPXMLReportPDFStyle.ruleColor) + context.setLineWidth(0.5) + context.move(to: CGPoint(x: FCPXMLReportPDFStyle.margin, y: cursorY + FCPXMLReportPDFStyle.rowHeight)) + context.addLine(to: CGPoint(x: FCPXMLReportPDFStyle.margin + tableWidth, y: cursorY + FCPXMLReportPDFStyle.rowHeight)) + context.strokePath() + + cursorY += FCPXMLReportPDFStyle.rowHeight + } + + func finishDocument() { + if hasOpenPage { + endPage() + } + context.closePDF() + } + + private func ensureVerticalSpace(_ height: CGFloat) { + guard cursorY + height > FCPXMLReportPDFStyle.contentBottom else { return } + + endPage() + beginContentPage( + pageTitle: runningPageTitle, + sheetColorIndex: runningSheetColorIndex, + contentHeading: runningContentHeading, + columnPart: runningColumnPart, + columnPartCount: runningColumnPartCount, + repeatOnContinuation: true + ) + } + + private func applySheetColors(sheetColorIndex: Int) { + runningSheetColorIndex = sheetColorIndex + runningSheetContentColor = FCPXMLReportPDFStyle.sheetContentBackgroundColor( + forSheetIndex: sheetColorIndex + ) + runningSheetAccentColor = FCPXMLReportPDFStyle.sheetAccentColor( + forSheetIndex: sheetColorIndex + ) + } + + private func beginPage(drawRunningBands: Bool, sheetColorIndex: Int? = nil) { + pageNumber += 1 + + var mediaBox = CGRect(origin: .zero, size: FCPXMLReportPDFStyle.pageSize) + context.beginPage(mediaBox: &mediaBox) + context.saveGState() + context.translateBy(x: 0, y: FCPXMLReportPDFStyle.pageSize.height) + context.scaleBy(x: 1, y: -1) + hasOpenPage = true + + if drawRunningBands { + if let sheetColorIndex { + applySheetColors(sheetColorIndex: sheetColorIndex) + } + drawPageChrome(includeSheetGrouping: sheetColorIndex != nil) + drawRunningHeader() + drawRunningFooter() + } else { + drawPageChrome(includeSheetGrouping: false) + } + } + + private func drawPageChrome(includeSheetGrouping: Bool) { + context.setFillColor(FCPXMLReportPDFStyle.pageBackgroundColor) + context.fill(CGRect(origin: .zero, size: FCPXMLReportPDFStyle.pageSize)) + + guard includeSheetGrouping else { return } + + context.setFillColor(runningSheetContentColor) + context.fill(FCPXMLReportPDFStyle.contentAreaRect) + + context.setFillColor(runningSheetAccentColor) + context.fill(CGRect( + x: FCPXMLReportPDFStyle.margin - FCPXMLReportPDFStyle.sheetAccentStripeWidth - 2, + y: FCPXMLReportPDFStyle.margin, + width: FCPXMLReportPDFStyle.sheetAccentStripeWidth, + height: FCPXMLReportPDFStyle.headerBandHeight + )) + } + + private func endPage() { + guard hasOpenPage else { return } + context.restoreGState() + context.endPage() + hasOpenPage = false + } + + private func drawRunningHeader() { + let baseline = FCPXMLReportPDFStyle.margin + FCPXMLReportPDFStyle.runningHeaderFontSize + + drawText( + projectName, + x: FCPXMLReportPDFStyle.margin, + y: baseline, + fontName: FCPXMLReportPDFStyle.boldFontName, + fontSize: FCPXMLReportPDFStyle.runningHeaderFontSize, + color: FCPXMLReportPDFStyle.textColor + ) + + var headerRight = runningPageTitle + if let runningColumnPart, + let runningColumnPartCount, + runningColumnPartCount > 1 + { + headerRight += " · Columns \(runningColumnPart) of \(runningColumnPartCount)" + } + + let rightWidth = FCPXMLReportPDFTableLayout.measuredWidth( + headerRight, + bold: false, + fontSize: FCPXMLReportPDFStyle.runningHeaderFontSize + ) + drawText( + headerRight, + x: max( + FCPXMLReportPDFStyle.margin, + FCPXMLReportPDFStyle.pageSize.width - FCPXMLReportPDFStyle.margin - rightWidth + ), + y: baseline, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: FCPXMLReportPDFStyle.runningHeaderFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + + drawHorizontalRule(atY: FCPXMLReportPDFStyle.headerRuleY) + } + + private func drawRunningFooter() { + drawHorizontalRule(atY: FCPXMLReportPDFStyle.footerRuleY) + + let baseline = FCPXMLReportPDFStyle.footerRuleY + + FCPXMLReportPDFStyle.footerRuleToTextSpacing + + FCPXMLReportPDFStyle.runningFooterFontSize + + let brandingLabel = FCPXMLReportPDFTableLayout.truncated( + exportBrandingText, + maxWidth: (FCPXMLReportPDFStyle.pageSize.width - (FCPXMLReportPDFStyle.margin * 2)) * 0.55, + fontSize: FCPXMLReportPDFStyle.runningFooterFontSize + ) + drawText( + brandingLabel, + x: FCPXMLReportPDFStyle.margin, + y: baseline, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: FCPXMLReportPDFStyle.runningFooterFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + + let pageLabel = "Page \(pageNumber)" + let pageLabelWidth = FCPXMLReportPDFTableLayout.measuredWidth( + pageLabel, + bold: false, + fontSize: FCPXMLReportPDFStyle.runningFooterFontSize + ) + drawText( + pageLabel, + x: FCPXMLReportPDFStyle.pageSize.width - FCPXMLReportPDFStyle.margin - pageLabelWidth, + y: baseline, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: FCPXMLReportPDFStyle.runningFooterFontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + } + + private func drawHorizontalRule(atY y: CGFloat) { + context.setStrokeColor(FCPXMLReportPDFStyle.ruleColor) + context.setLineWidth(0.5) + context.move(to: CGPoint(x: FCPXMLReportPDFStyle.margin, y: y)) + context.addLine(to: CGPoint( + x: FCPXMLReportPDFStyle.pageSize.width - FCPXMLReportPDFStyle.margin, + y: y + )) + context.strokePath() + } + + private func drawTOCTableDataRow( + index: Int, + title: String, + startPage: Int, + columnWidths: [CGFloat] + ) { + ensureVerticalSpace(FCPXMLReportPDFStyle.rowHeight) + + let originX = FCPXMLReportPDFStyle.margin + let tableWidth = columnWidths.reduce(0, +) + let fontSize = FCPXMLReportPDFStyle.bodyFontSize + let baseline = cursorY + fontSize + 3 + + let indexText = "\(index)" + drawText( + indexText, + x: originX + FCPXMLReportPDFStyle.cellPadding, + y: baseline, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: fontSize, + color: FCPXMLReportPDFStyle.mutedTextColor + ) + + let sheetX = originX + columnWidths[0] + let sheetWidth = columnWidths[1] + let sheetText = FCPXMLReportPDFTableLayout.truncated( + title, + maxWidth: sheetWidth, + fontSize: fontSize + ) + drawText( + sheetText, + x: sheetX + FCPXMLReportPDFStyle.cellPadding, + y: baseline, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: fontSize, + color: FCPXMLReportPDFStyle.textColor + ) + + let pageText = "\(startPage)" + let pageColumnX = originX + columnWidths[0] + columnWidths[1] + let pageColumnWidth = columnWidths[2] + let pageTextWidth = FCPXMLReportPDFTableLayout.measuredWidth( + pageText, + bold: false, + fontSize: fontSize + ) + drawText( + pageText, + x: pageColumnX + pageColumnWidth - FCPXMLReportPDFStyle.cellPadding - pageTextWidth, + y: baseline, + fontName: FCPXMLReportPDFStyle.regularFontName, + fontSize: fontSize, + color: FCPXMLReportPDFStyle.textColor + ) + + context.setStrokeColor(FCPXMLReportPDFStyle.ruleColor) + context.setLineWidth(0.5) + context.move(to: CGPoint(x: originX, y: cursorY + FCPXMLReportPDFStyle.rowHeight)) + context.addLine(to: CGPoint(x: originX + tableWidth, y: cursorY + FCPXMLReportPDFStyle.rowHeight)) + context.strokePath() + + cursorY += FCPXMLReportPDFStyle.rowHeight + } + + private func drawText( + _ text: String, + x: CGFloat, + y: CGFloat, + fontName: String, + fontSize: CGFloat, + color: CGColor + ) { + guard !text.isEmpty else { return } + + let font = CTFontCreateWithName(fontName as CFString, fontSize, nil) + let attributes: [NSAttributedString.Key: Any] = [ + kCTFontAttributeName as NSAttributedString.Key: font, + kCTForegroundColorAttributeName as NSAttributedString.Key: color, + ] + let attributed = NSAttributedString(string: text, attributes: attributes) + let line = CTLineCreateWithAttributedString(attributed) + + context.saveGState() + context.setTextDrawingMode(.fill) + context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) + context.textPosition = CGPoint(x: x, y: y) + CTLineDraw(line, context) + context.restoreGState() + } + } +} diff --git a/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFCoverNotes.swift b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFCoverNotes.swift new file mode 100644 index 0000000..d9a87d4 --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFCoverNotes.swift @@ -0,0 +1,29 @@ +// +// FCPXMLReportPDFCoverNotes.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// Cover-page guidance shown on PDF report exports. +// + +import Foundation + +enum FCPXMLReportPDFCoverNotes { + static let title = "About This PDF Export" + + static let paragraphs = [ + """ + PDF report export is experimental. This document is optimised for A4 landscape \ + review and distribution. Column titles, cell values, and metadata may be truncated \ + where they exceed the available page width or column layout limits. Wide tables are \ + paginated horizontally into column sets and vertically across continuation pages. The Row \ + column is repeated on every continuation and column-set page so row numbers stay traceable \ + across the sheet. All pages that belong to the same workbook sheet share a subtle background \ + tint and header accent colour so spanned sections remain visually grouped. For the complete, \ + untruncated dataset, column customisation, and workbook formatting options, please \ + refer to the accompanying Excel (.xlsx) report exported alongside this PDF. + """, + ] +} diff --git a/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFExport.swift b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFExport.swift new file mode 100644 index 0000000..3422978 --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFExport.swift @@ -0,0 +1,50 @@ +// +// FCPXMLReportPDFExport.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// PDF export of ``FinalCutPro/FCPXML/Report`` models. +// + +import Foundation + +extension FinalCutPro.FCPXML { + /// Errors thrown while exporting a report to PDF. + public enum ReportPDFExportError: Error, LocalizedError, Sendable { + case couldNotCreateDocument + case couldNotWriteFile + + public var errorDescription: String? { + switch self { + case .couldNotCreateDocument: + return "Could not create the PDF document." + case .couldNotWriteFile: + return "Could not write the PDF file." + } + } + } + + /// Maps ``Report`` to PDF documents using CoreGraphics. + /// + /// PDF cover/footer branding uses ``Report/exportBrandingText``, which reads + /// ``Report/workbookCoverSheet`` — the same ``ReportWorkbookCoverSheet`` settings as Excel. + public enum ReportPDFExport { + /// Builds PDF data from a structured FCPXML report. + public static func makePDFData(from report: Report) throws -> Data { + try FCPXMLReportPDFExporter.makePDFData(from: report) + } + + /// Writes a report to a `.pdf` file at the given URL. + public static func export(_ report: Report, to url: URL) throws { + let data = try makePDFData(from: report) + + do { + try data.write(to: url, options: .atomic) + } catch { + throw ReportPDFExportError.couldNotWriteFile + } + } + } +} diff --git a/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFExporter.swift b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFExporter.swift new file mode 100644 index 0000000..b9410a3 --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFExporter.swift @@ -0,0 +1,570 @@ +// +// FCPXMLReportPDFExporter.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// Maps ``FinalCutPro/FCPXML/Report`` sections to a multi-page PDF document. +// + +import CoreGraphics +import Foundation + +enum FCPXMLReportPDFExporter { + static func makePDFData(from report: FinalCutPro.FCPXML.Report) throws -> Data { + let plannedSheets = FCPXMLReportPDFSheetPlan.plannedSheets(from: report) + let colorIndexByTitle = FCPXMLReportPDFSheetPlan.colorIndexLookup(for: plannedSheets) + + guard !plannedSheets.isEmpty else { + return try renderPDF( + from: report, + colorIndexByTitle: colorIndexByTitle, + tableOfContents: nil, + reservedTOCPages: 0, + recordsSectionStarts: false + ) + } + + let reservedTOCPages = FCPXMLReportPDFSheetPlan.tableOfContentsPageCount( + entryCount: plannedSheets.count + ) + var recordedStarts: [(title: String, startPage: Int)] = [] + + _ = try renderPDF( + from: report, + colorIndexByTitle: colorIndexByTitle, + tableOfContents: nil, + reservedTOCPages: reservedTOCPages, + recordsSectionStarts: true, + sectionStartSink: { title, startPage in + recordedStarts.append((title, startPage)) + } + ) + + let tableOfContents = FCPXMLReportPDFSheetPlan.tableOfContentsEntries( + from: plannedSheets, + recorded: recordedStarts + ) + + return try renderPDF( + from: report, + colorIndexByTitle: colorIndexByTitle, + tableOfContents: tableOfContents, + reservedTOCPages: 0, + recordsSectionStarts: false + ) + } + + private static func renderPDF( + from report: FinalCutPro.FCPXML.Report, + colorIndexByTitle: [String: Int], + tableOfContents: [FCPXMLReportPDFSheetPlan.SheetEntry]?, + reservedTOCPages: Int, + recordsSectionStarts: Bool, + sectionStartSink: ((String, Int) -> Void)? = nil + ) throws -> Data { + let data = NSMutableData() + + guard let consumer = CGDataConsumer(data: data as CFMutableData) else { + throw FinalCutPro.FCPXML.ReportPDFExportError.couldNotCreateDocument + } + + var mediaBox = CGRect(origin: .zero, size: FCPXMLReportPDFStyle.pageSize) + guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { + throw FinalCutPro.FCPXML.ReportPDFExportError.couldNotCreateDocument + } + + let canvas = FCPXMLReportPDFCanvas.Builder(context: context) + canvas.configureDocument( + projectName: report.projectName, + eventName: report.eventName, + exportBrandingText: report.exportBrandingText, + sectionStartRecorder: recordsSectionStarts ? sectionStartSink : nil + ) + canvas.drawCoverPage(projectName: report.projectName, eventName: report.eventName) + + if let tableOfContents, !tableOfContents.isEmpty { + canvas.drawTableOfContents(entries: tableOfContents) + } else if reservedTOCPages > 0 { + canvas.reserveBlankPages(reservedTOCPages) + } + + let excludedColumns = report.excludedColumns + let timecodeFormat = report.timecodeFormat + + if let roleInventory = report.roleInventory { + appendRoleInventory( + roleInventory, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let markers = report.markers { + appendMarkers( + markers, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let keywords = report.keywords { + appendKeywords( + keywords, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let titlesAndGenerators = report.titlesAndGenerators { + appendTitlesAndGenerators( + titlesAndGenerators, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let transitions = report.transitions { + appendTransitions( + transitions, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let effects = report.effects { + appendEffects( + effects, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let speedChangeEffects = report.speedChangeEffects { + appendSpeedChangeEffects( + speedChangeEffects, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let summary = report.summary { + appendSummary( + summary, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + if let mediaSummary = report.mediaSummary { + appendMediaSummary( + mediaSummary, + excludedColumns: excludedColumns, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + to: canvas + ) + } + + canvas.finishDocument() + return data as Data + } + + private static func sheetColorIndex( + for title: String, + colorIndexByTitle: [String: Int] + ) -> Int { + colorIndexByTitle[title] ?? 0 + } + + private static func appendRoleInventory( + _ roleInventory: FinalCutPro.FCPXML.RoleInventoryReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + let metadataColumnKeys = roleInventory.metadataColumnKeys + let headers = FinalCutPro.FCPXML.RoleInventoryColumnLayout.columnHeaders( + metadataColumnKeys: metadataColumnKeys, + excludedColumns: excludedColumns, + timecodeFormat: timecodeFormat + ) + + guard !headers.isEmpty else { return } + + let selectedSheetName = FinalCutPro.FCPXML.RoleInventoryReportSection.defaultSheetName + let selectedRows = roleInventory.selectedRoles.enumerated().map { index, row in + FinalCutPro.FCPXML.RoleInventoryColumnLayout.columnValues( + for: row, + rowIndex: index + 1, + metadataColumnKeys: metadataColumnKeys, + excludedColumns: excludedColumns + ) + } + + if !selectedRows.isEmpty { + canvas.drawTable( + context: FCPXMLReportPDFTableRenderer.TableDrawContext( + pageTitle: selectedSheetName, + sheetColorIndex: sheetColorIndex(for: selectedSheetName, colorIndexByTitle: colorIndexByTitle), + contentHeading: nil, + recordsSectionStart: recordsSectionStarts + ), + headers: headers, + rows: selectedRows, + rowTextColorForRow: { _, values in + FCPXMLReportRowColorPolicy.textColor( + forRowValues: values, + headers: headers, + context: .roleInventory, + defaultColor: FCPXMLReportPDFStyle.textColor + ) + } + ) + } + + for roleSheet in roleInventory.roleSheets { + let rows = roleSheet.rows.enumerated().map { index, row in + FinalCutPro.FCPXML.RoleInventoryColumnLayout.columnValues( + for: row, + rowIndex: index + 1, + metadataColumnKeys: metadataColumnKeys, + excludedColumns: excludedColumns + ) + } + + guard !rows.isEmpty else { continue } + + let sheetTitle = FinalCutPro.FCPXML.RoleInventoryRoleSheetOrdering.sheetTabName( + for: roleSheet.sheetName + ) + + canvas.drawTable( + context: FCPXMLReportPDFTableRenderer.TableDrawContext( + pageTitle: sheetTitle, + sheetColorIndex: sheetColorIndex(for: sheetTitle, colorIndexByTitle: colorIndexByTitle), + contentHeading: nil, + recordsSectionStart: recordsSectionStarts + ), + headers: headers, + rows: rows, + rowTextColorForRow: { _, values in + FCPXMLReportRowColorPolicy.textColor( + forRowValues: values, + headers: headers, + context: .roleInventory, + defaultColor: FCPXMLReportPDFStyle.textColor + ) + } + ) + } + } + + private static func appendMarkers( + _ markers: FinalCutPro.FCPXML.MarkersReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + let filtered = filteredTabularSection( + headers: FinalCutPro.FCPXML.MarkerReportRow.columnHeaders( + timecodeFormat: timecodeFormat + ), + rows: markers.rows.map(\.columnValues), + excludedColumns: excludedColumns + ) + + guard !filtered.headers.isEmpty else { return } + + let sheetName = FinalCutPro.FCPXML.MarkersReportSection.defaultSheetName + canvas.drawTable( + context: FCPXMLReportPDFTableRenderer.TableDrawContext( + pageTitle: sheetName, + sheetColorIndex: sheetColorIndex(for: sheetName, colorIndexByTitle: colorIndexByTitle), + contentHeading: nil, + recordsSectionStart: recordsSectionStarts + ), + headers: filtered.headers, + rows: filtered.rows, + rowTextColorForRow: { index, _ in + FCPXMLReportRowColorPolicy.markerCGColor(for: markers.rows[index].type) + ?? FCPXMLReportPDFStyle.textColor + } + ) + } + + private static func appendKeywords( + _ keywords: FinalCutPro.FCPXML.KeywordsReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + appendTabularSection( + sheetName: FinalCutPro.FCPXML.KeywordsReportSection.defaultSheetName, + headers: FinalCutPro.FCPXML.KeywordReportRow.columnHeaders(timecodeFormat: timecodeFormat), + rows: keywords.rows.map(\.columnValues), + excludedColumns: excludedColumns, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + colorContext: .keywords, + to: canvas + ) + } + + private static func appendTitlesAndGenerators( + _ titles: FinalCutPro.FCPXML.TitlesReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + appendTabularSection( + sheetName: FinalCutPro.FCPXML.TitlesReportSection.defaultSheetName, + headers: FinalCutPro.FCPXML.TitleReportRow.columnHeaders(timecodeFormat: timecodeFormat), + rows: titles.rows.map(\.columnValues), + excludedColumns: excludedColumns, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + colorContext: .titlesAndGenerators, + to: canvas + ) + } + + private static func appendTransitions( + _ transitions: FinalCutPro.FCPXML.TransitionsReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + appendTabularSection( + sheetName: FinalCutPro.FCPXML.TransitionsReportSection.defaultSheetName, + headers: FinalCutPro.FCPXML.TransitionReportRow.columnHeaders(timecodeFormat: timecodeFormat), + rows: transitions.rows.map(\.columnValues), + excludedColumns: excludedColumns, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + colorContext: .transitions, + to: canvas + ) + } + + private static func appendEffects( + _ effects: FinalCutPro.FCPXML.EffectsReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + appendTabularSection( + sheetName: FinalCutPro.FCPXML.EffectsReportSection.defaultSheetName, + headers: FinalCutPro.FCPXML.EffectReportRow.columnHeaders(timecodeFormat: timecodeFormat), + rows: effects.rows.map(\.columnValues), + excludedColumns: excludedColumns, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + colorContext: .effects, + to: canvas + ) + } + + private static func appendSpeedChangeEffects( + _ speedChangeEffects: FinalCutPro.FCPXML.SpeedChangeEffectsReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + appendTabularSection( + sheetName: FinalCutPro.FCPXML.SpeedChangeEffectsReportSection.defaultSheetName, + headers: FinalCutPro.FCPXML.EffectReportRow.columnHeaders(timecodeFormat: timecodeFormat), + rows: speedChangeEffects.rows.map(\.columnValues), + excludedColumns: excludedColumns, + colorIndexByTitle: colorIndexByTitle, + recordsSectionStarts: recordsSectionStarts, + colorContext: .speedChangeEffects, + to: canvas + ) + } + + private static func appendTabularSection( + sheetName: String, + headers: [String], + rows: [[String]], + excludedColumns: Set, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + colorContext: FCPXMLReportRowColorPolicy.Context, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + let filtered = filteredTabularSection( + headers: headers, + rows: rows, + excludedColumns: excludedColumns + ) + + guard !filtered.headers.isEmpty, !filtered.rows.isEmpty else { return } + + canvas.drawTable( + context: FCPXMLReportPDFTableRenderer.TableDrawContext( + pageTitle: sheetName, + sheetColorIndex: sheetColorIndex(for: sheetName, colorIndexByTitle: colorIndexByTitle), + contentHeading: nil, + recordsSectionStart: recordsSectionStarts + ), + headers: filtered.headers, + rows: filtered.rows, + rowTextColorForRow: { _, values in + FCPXMLReportRowColorPolicy.textColor( + forRowValues: values, + headers: filtered.headers, + context: colorContext, + defaultColor: FCPXMLReportPDFStyle.textColor + ) + } + ) + } + + private static func appendSummary( + _ summary: FinalCutPro.FCPXML.SummaryReportSection, + excludedColumns: Set, + timecodeFormat: FinalCutPro.FCPXML.ReportTimecodeFormat, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + let sheetName = FinalCutPro.FCPXML.SummaryReportSection.defaultSheetName + let colorIndex = sheetColorIndex(for: sheetName, colorIndexByTitle: colorIndexByTitle) + + if let projectSummary = summary.projectSummary { + canvas.beginContentPage( + pageTitle: sheetName, + sheetColorIndex: colorIndex, + contentHeading: nil, + recordsSectionStart: recordsSectionStarts + ) + canvas.drawSummaryProjectTitle(projectSummary.title) + + let metrics = FinalCutPro.FCPXML.ReportColumnExclusion.filteredProjectSummaryMetrics( + projectSummary, + excluded: excludedColumns + ) + let metricLine = metrics + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " · ") + + if !metricLine.isEmpty { + canvas.drawBodyLine(metricLine, color: FCPXMLReportPDFStyle.mutedTextColor) + } + + canvas.endContentPage() + } + + if !summary.roleDurations.isEmpty { + let filtered = filteredTabularSection( + headers: FinalCutPro.FCPXML.SummaryRoleDurationRow.columnHeaders( + timecodeFormat: timecodeFormat + ), + rows: summary.roleDurations.map(\.columnValues), + excludedColumns: excludedColumns + ) + + if !filtered.headers.isEmpty { + canvas.drawTable( + context: FCPXMLReportPDFTableRenderer.TableDrawContext( + pageTitle: sheetName, + sheetColorIndex: colorIndex, + contentHeading: summary.projectSummary == nil ? nil : "Role Durations", + recordsSectionStart: recordsSectionStarts && summary.projectSummary == nil + ), + headers: filtered.headers, + rows: filtered.rows + ) + } + } + } + + private static func appendMediaSummary( + _ mediaSummary: FinalCutPro.FCPXML.MediaSummaryReportSection, + excludedColumns: Set, + colorIndexByTitle: [String: Int], + recordsSectionStarts: Bool, + to canvas: FCPXMLReportPDFCanvas.Builder + ) { + let missingMediaTitle = FinalCutPro.FCPXML.MediaSummaryReportSection.missingMediaSectionTitle + + guard !mediaSummary.missingMediaPaths.isEmpty, + !FinalCutPro.FCPXML.ReportColumnExclusion.isHeaderExcluded( + missingMediaTitle, + excluded: excludedColumns, + metadataColumnKeys: [] + ) + else { + return + } + + let sheetName = FinalCutPro.FCPXML.MediaSummaryReportSection.defaultSheetName + canvas.beginContentPage( + pageTitle: sheetName, + sheetColorIndex: sheetColorIndex(for: sheetName, colorIndexByTitle: colorIndexByTitle), + contentHeading: missingMediaTitle, + recordsSectionStart: recordsSectionStarts + ) + + for path in mediaSummary.missingMediaPaths { + canvas.drawBodyLine(path, color: FCPXMLReportPDFStyle.missingMediaTextColor) + } + + canvas.endContentPage() + } + + private static func filteredTabularSection( + headers: [String], + rows: [[String]], + excludedColumns: Set, + metadataColumnKeys: [String] = [] + ) -> (headers: [String], rows: [[String]]) { + FinalCutPro.FCPXML.ReportColumnExclusion.filter( + headers: headers, + rows: rows, + excluded: excludedColumns, + metadataColumnKeys: metadataColumnKeys + ) + } +} diff --git a/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFSheetPlan.swift b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFSheetPlan.swift new file mode 100644 index 0000000..8a3248a --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFSheetPlan.swift @@ -0,0 +1,128 @@ +// +// FCPXMLReportPDFSheetPlan.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// Ordered workbook sheet plan for PDF section colours and table of contents. +// + +import Foundation + +enum FCPXMLReportPDFSheetPlan { + /// One workbook sheet in PDF export order. + struct SheetEntry: Sendable, Equatable { + let title: String + let colorIndex: Int + var startPage: Int + + init(title: String, colorIndex: Int, startPage: Int = 0) { + self.title = title + self.colorIndex = colorIndex + self.startPage = startPage + } + } + + /// Builds ordered sheet entries mirroring Excel workbook sheet order. + static func plannedSheets(from report: FinalCutPro.FCPXML.Report) -> [SheetEntry] { + var titles: [String] = [] + + if let roleInventory = report.roleInventory { + if !roleInventory.selectedRoles.isEmpty { + titles.append(FinalCutPro.FCPXML.RoleInventoryReportSection.defaultSheetName) + } + + for roleSheet in roleInventory.roleSheets where !roleSheet.rows.isEmpty { + titles.append( + FinalCutPro.FCPXML.RoleInventoryRoleSheetOrdering.sheetTabName( + for: roleSheet.sheetName + ) + ) + } + } + + if let markers = report.markers, !markers.rows.isEmpty { + titles.append(FinalCutPro.FCPXML.MarkersReportSection.defaultSheetName) + } + + if let keywords = report.keywords, !keywords.rows.isEmpty { + titles.append(FinalCutPro.FCPXML.KeywordsReportSection.defaultSheetName) + } + + if let titlesAndGenerators = report.titlesAndGenerators, !titlesAndGenerators.rows.isEmpty { + titles.append(FinalCutPro.FCPXML.TitlesReportSection.defaultSheetName) + } + + if let transitions = report.transitions, !transitions.rows.isEmpty { + titles.append(FinalCutPro.FCPXML.TransitionsReportSection.defaultSheetName) + } + + if let effects = report.effects, !effects.rows.isEmpty { + titles.append(FinalCutPro.FCPXML.EffectsReportSection.defaultSheetName) + } + + if let speedChangeEffects = report.speedChangeEffects, !speedChangeEffects.rows.isEmpty { + titles.append(FinalCutPro.FCPXML.SpeedChangeEffectsReportSection.defaultSheetName) + } + + if let summary = report.summary, + summary.projectSummary != nil || !summary.roleDurations.isEmpty + { + titles.append(FinalCutPro.FCPXML.SummaryReportSection.defaultSheetName) + } + + if let mediaSummary = report.mediaSummary, + !mediaSummary.missingMediaPaths.isEmpty, + !FinalCutPro.FCPXML.ReportColumnExclusion.isHeaderExcluded( + FinalCutPro.FCPXML.MediaSummaryReportSection.missingMediaSectionTitle, + excluded: report.excludedColumns, + metadataColumnKeys: [] + ) + { + titles.append(FinalCutPro.FCPXML.MediaSummaryReportSection.defaultSheetName) + } + + return titles.enumerated().map { index, title in + SheetEntry(title: title, colorIndex: index) + } + } + + static func colorIndexLookup(for sheets: [SheetEntry]) -> [String: Int] { + Dictionary(uniqueKeysWithValues: sheets.map { ($0.title, $0.colorIndex) }) + } + + static func tableOfContentsPageCount(entryCount: Int) -> Int { + guard entryCount > 0 else { return 0 } + let rowsPerPage = tocRowsPerPage() + return max(1, Int(ceil(Double(entryCount) / Double(rowsPerPage)))) + } + + static func tocRowsPerPage() -> Int { + let available = FCPXMLReportPDFStyle.contentBottom + - FCPXMLReportPDFStyle.contentTop + - FCPXMLReportPDFStyle.sectionTitleFontSize + - 10 + - FCPXMLReportPDFStyle.headerRowHeight + guard available > 0 else { return 1 } + return max(1, Int(floor(available / FCPXMLReportPDFStyle.rowHeight))) + } + + static func tableOfContentsEntries( + from planned: [SheetEntry], + recorded: [(title: String, startPage: Int)] + ) -> [SheetEntry] { + let pagesByTitle = Dictionary( + recorded.map { ($0.title, $0.startPage) }, + uniquingKeysWith: { first, _ in first } + ) + + return planned.map { entry in + var updated = entry + if let page = pagesByTitle[entry.title] { + updated.startPage = page + } + return updated + } + } +} diff --git a/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFStyle.swift b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFStyle.swift new file mode 100644 index 0000000..0033e11 --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFStyle.swift @@ -0,0 +1,228 @@ +// +// FCPXMLReportPDFStyle.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// Minimal visual constants for PDF report export. +// + +import CoreGraphics +import Foundation + +enum FCPXMLReportPDFStyle { + // MARK: - Page layout + + /// ISO 216 A4 landscape (297 × 210 mm) in PDF points (72 pt/in). + static let pageSize = CGSize(width: 841.89, height: 595.28) + + /// Outer margin on all four sides of every page. + static let margin: CGFloat = 36 + + /// Height reserved for the running header band (project name and sheet title). + static let headerBandHeight: CGFloat = 32 + + /// Height reserved for the running footer band (branding and page number). + static let footerBandHeight: CGFloat = 32 + + /// Vertical gap between the footer rule line and footer text baseline. + static let footerRuleToTextSpacing: CGFloat = 10 + + /// Space after a section title block before table content begins. + static let sectionSpacing: CGFloat = 20 + + /// Space after a completed table before the next block on the same page. + static let tableSpacing: CGFloat = 12 + + /// Width of the coloured stripe beside the running header for sheet grouping. + static let sheetAccentStripeWidth: CGFloat = 4 + + // MARK: - Typography (Menlo / Menlo-Bold) + + /// Cover page — project name (`drawCoverPage`). + static let coverTitleFontSize: CGFloat = 24 + + /// Cover page — event name, generated-on timestamp, and branding line. + static let coverSubtitleFontSize: CGFloat = 13 + + /// Cover info box — “About This PDF Export” heading. + static let coverInfoTitleFontSize: CGFloat = 10 + + /// Cover info box — wrapped guidance paragraph body text. + static let coverInfoBodyFontSize: CGFloat = 8.5 + + /// Inner padding for the cover info box on all sides. + static let coverInfoBoxPadding: CGFloat = 12 + + /// Line spacing between wrapped lines inside the cover info box body. + static let coverInfoLineSpacing: CGFloat = 4 + + /// Space between the info box title and body, and between wrapped paragraphs. + static let coverInfoParagraphSpacing: CGFloat = 8 + + /// Running header — project name (bold) and sheet title / column-set label (regular). + static let runningHeaderFontSize: CGFloat = 9 + + /// Running footer — export branding and page number. + static let runningFooterFontSize: CGFloat = 8 + + /// Content pages — primary section title (workbook sheet name on the first page of a section). + static let sectionTitleFontSize: CGFloat = 14 + + /// Content pages — secondary heading (e.g. “Role Durations”, missing-media title). + static let subsectionTitleFontSize: CGFloat = 11 + + /// Table column headers — black header row (bold). + static let headerFontSize: CGFloat = 8 + + /// Table body cells, table of contents rows, summary metrics, and standalone body lines. + static let bodyFontSize: CGFloat = 8 + + /// Summary sheet — project title rendered above role-duration metrics. + static let summaryTitleFontSize: CGFloat = 13 + + // MARK: - Table layout + + /// Height of one table data row (drives vertical pagination). + static let rowHeight: CGFloat = 16 + + /// Height of the black table header row. + static let headerRowHeight: CGFloat = 20 + + /// Horizontal inset inside each table cell before text is drawn. + static let cellPadding: CGFloat = 4 + + /// Minimum column width when chunking wide tables horizontally. + static let minColumnWidth: CGFloat = 36 + + /// Maximum column width before truncation or further chunking. + static let maxColumnWidth: CGFloat = 140 + + // MARK: - Derived layout + + /// Y position of the running header rule; top edge of the per-sheet tinted content zone. + static var headerRuleY: CGFloat { + margin + headerBandHeight + } + + /// Y position of the running footer rule; bottom edge of the per-sheet tinted content zone. + static var footerRuleY: CGFloat { + pageSize.height - margin - footerBandHeight + } + + /// Usable width for tables and cover info box (page width minus side margins). + static var contentWidth: CGFloat { + pageSize.width - (margin * 2) + } + + /// Y origin where table and body content begins below the header rule. + static var contentTop: CGFloat { + headerRuleY + 6 + } + + /// Y limit above the footer rule where table and body content must stop. + static var contentBottom: CGFloat { + footerRuleY - 4 + } + + /// Full-width per-sheet tint between the header rule and footer rule (content zone only). + static var contentAreaRect: CGRect { + CGRect( + x: 0, + y: headerRuleY, + width: pageSize.width, + height: footerRuleY - headerRuleY + ) + } + + // MARK: - Colours + + private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB() + + /// Default body and table data text (near-black). + static let textColor = CGColor(colorSpace: rgbColorSpace, components: [0.12, 0.12, 0.12, 1])! + + /// Secondary text — cover subtitles, running header sheet label, footer, summary metrics. + static let mutedTextColor = CGColor(colorSpace: rgbColorSpace, components: [0.45, 0.45, 0.45, 1])! + + /// Table header row fill (matches Excel black header background). + static let headerBackgroundColor = CGColor(colorSpace: rgbColorSpace, components: [0, 0, 0, 1])! + + /// Table header row text (white on black). + static let headerTextColor = CGColor(colorSpace: rgbColorSpace, components: [1, 1, 1, 1])! + + /// Horizontal rules marking the top and bottom of the per-sheet tinted content zone. + static let ruleColor = CGColor(colorSpace: rgbColorSpace, components: [0.82, 0.82, 0.82, 1])! + + /// Media Summary missing-media file paths (matches Excel red path text). + static let missingMediaTextColor = CGColor(colorSpace: rgbColorSpace, components: [0.78, 0.16, 0.16, 1])! + + /// Page background outside the per-sheet content tint. + static let pageBackgroundColor = CGColor(colorSpace: rgbColorSpace, components: [1, 1, 1, 1])! + + /// Cover info box fill. + static let coverInfoBoxBackgroundColor = CGColor( + colorSpace: rgbColorSpace, + components: [0.97, 0.97, 0.98, 1] + )! + + /// Cover info box border stroke. + static let coverInfoBoxBorderColor = CGColor(colorSpace: rgbColorSpace, components: [0.86, 0.86, 0.88, 1])! + + // MARK: - Per-sheet palettes + + /// Subtle content-area background tints cycled by workbook sheet index. + private static let sheetContentPalette: [(CGFloat, CGFloat, CGFloat)] = [ + (0.93, 0.96, 1.00), + (0.92, 0.98, 0.93), + (1.00, 0.95, 0.90), + (0.96, 0.93, 1.00), + (0.98, 0.98, 0.90), + (0.90, 0.96, 0.98), + (0.98, 0.92, 0.95), + (0.94, 0.98, 0.96), + (0.97, 0.94, 0.90), + (0.91, 0.94, 0.99), + ] + + /// Header accent stripe colours cycled by workbook sheet index. + private static let sheetAccentPalette: [(CGFloat, CGFloat, CGFloat)] = [ + (0.55, 0.72, 0.95), + (0.45, 0.78, 0.52), + (0.95, 0.62, 0.38), + (0.68, 0.52, 0.88), + (0.82, 0.78, 0.28), + (0.38, 0.72, 0.82), + (0.88, 0.48, 0.62), + (0.42, 0.78, 0.68), + (0.90, 0.68, 0.38), + (0.48, 0.62, 0.90), + ] + + /// Content-area tint for every page in a spanning sheet (column chunks and continuations). + static func sheetContentBackgroundColor(forSheetIndex sheetIndex: Int) -> CGColor { + let components = sheetContentPalette[sheetIndex % sheetContentPalette.count] + return CGColor( + colorSpace: rgbColorSpace, + components: [components.0, components.1, components.2, 1] + )! + } + + /// Left header accent stripe matching the sheet group colour. + static func sheetAccentColor(forSheetIndex sheetIndex: Int) -> CGColor { + let components = sheetAccentPalette[sheetIndex % sheetAccentPalette.count] + return CGColor( + colorSpace: rgbColorSpace, + components: [components.0, components.1, components.2, 1] + )! + } + + // MARK: - Fonts + + /// Regular body font — all non-bold PDF text. + static let regularFontName = "Menlo" + + /// Bold font — cover title, section headings, and table column headers. + static let boldFontName = "Menlo-Bold" +} diff --git a/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFTableLayout.swift b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFTableLayout.swift new file mode 100644 index 0000000..4453036 --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/PDF/FCPXMLReportPDFTableLayout.swift @@ -0,0 +1,334 @@ +// +// FCPXMLReportPDFTableLayout.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// Column sizing, chunking, and text truncation for PDF tables. +// + +import CoreGraphics +import CoreText +import Foundation + +enum FCPXMLReportPDFTableLayout { + struct ColumnChunk: Sendable { + let columnIndices: [Int] + let widths: [CGFloat] + + var totalWidth: CGFloat { + widths.reduce(0, +) + } + + func headers(from allHeaders: [String]) -> [String] { + columnIndices.map { allHeaders[$0] } + } + + func rowSlice(from row: [String]) -> [String] { + columnIndices.map { index in + row.indices.contains(index) ? row[index] : "" + } + } + } + + /// Header label for the traceability row index column (matches workbook ``ReportColumn/row``). + static let rowColumnHeader = FinalCutPro.FCPXML.RoleInventoryColumnLayout.rowColumnHeader + + /// Prepares headers/rows for paginated PDF tables, injecting and pinning ``rowColumnHeader`` when needed. + static func preparePaginatedTable( + headers: [String], + rows: [[String]], + contentWidth: CGFloat + ) -> (headers: [String], rows: [[String]], pinnedColumnIndices: [Int]) { + guard !headers.isEmpty else { return (headers, rows, []) } + + let rowsPerPage = rowsPerPage() + let unpinnedChunks = columnChunks( + headers: headers, + rows: rows, + contentWidth: contentWidth, + pinnedColumnIndices: [] + ) + let spansMultiplePages = unpinnedChunks.count > 1 || rows.count > rowsPerPage + + var preparedHeaders = headers + var preparedRows = rows + + if spansMultiplePages, rowColumnIndex(in: preparedHeaders) == nil { + preparedHeaders = [rowColumnHeader] + preparedHeaders + preparedRows = rows.enumerated().map { index, row in + [String(index + 1)] + row + } + } + + let pinnedColumnIndices = rowColumnIndex(in: preparedHeaders).map { [$0] } ?? [] + return (preparedHeaders, preparedRows, pinnedColumnIndices) + } + + static func columnChunks( + headers: [String], + rows: [[String]], + contentWidth: CGFloat, + pinnedColumnIndices: [Int] = [] + ) -> [ColumnChunk] { + guard !headers.isEmpty else { return [] } + + let naturalWidths = naturalColumnWidths(headers: headers, rows: rows) + let pinnedSet = Set(pinnedColumnIndices) + let pinnedIndices = pinnedColumnIndices.filter { headers.indices.contains($0) } + let pinnedWidth = pinnedIndices.reduce(CGFloat.zero) { total, index in + total + clampedColumnWidth(naturalWidths[index]) + } + let chunkableWidth = max(contentWidth - pinnedWidth, FCPXMLReportPDFStyle.minColumnWidth) + + var chunks: [ColumnChunk] = [] + var indices: [Int] = [] + var widths: [CGFloat] = [] + var usedWidth: CGFloat = 0 + + for index in headers.indices where !pinnedSet.contains(index) { + let columnWidth = clampedColumnWidth(naturalWidths[index]) + + if !indices.isEmpty, usedWidth + columnWidth > chunkableWidth { + chunks.append( + makeChunk( + pinnedIndices: pinnedIndices, + pinnedWidths: pinnedIndices.map { clampedColumnWidth(naturalWidths[$0]) }, + chunkIndices: indices, + chunkWidths: widths + ) + ) + indices = [] + widths = [] + usedWidth = 0 + } + + indices.append(index) + widths.append(columnWidth) + usedWidth += columnWidth + } + + if !indices.isEmpty || chunks.isEmpty { + chunks.append( + makeChunk( + pinnedIndices: pinnedIndices, + pinnedWidths: pinnedIndices.map { clampedColumnWidth(naturalWidths[$0]) }, + chunkIndices: indices, + chunkWidths: widths + ) + ) + } + + return chunks + } + + private static func makeChunk( + pinnedIndices: [Int], + pinnedWidths: [CGFloat], + chunkIndices: [Int], + chunkWidths: [CGFloat] + ) -> ColumnChunk { + ColumnChunk( + columnIndices: pinnedIndices + chunkIndices, + widths: pinnedWidths + chunkWidths + ) + } + + private static func clampedColumnWidth(_ naturalWidth: CGFloat) -> CGFloat { + min( + max(naturalWidth, FCPXMLReportPDFStyle.minColumnWidth), + FCPXMLReportPDFStyle.maxColumnWidth + ) + } + + private static func rowColumnIndex(in headers: [String]) -> Int? { + headers.firstIndex { isRowColumnHeader($0) } + } + + private static func isRowColumnHeader(_ header: String) -> Bool { + header.compare(rowColumnHeader, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + + static func rowsPerPage() -> Int { + let available = FCPXMLReportPDFStyle.contentBottom - FCPXMLReportPDFStyle.contentTop + - FCPXMLReportPDFStyle.sectionTitleFontSize - 12 + - FCPXMLReportPDFStyle.headerRowHeight + guard available > 0 else { return 1 } + return max(1, Int(floor(available / FCPXMLReportPDFStyle.rowHeight))) + } + + static func truncated( + _ text: String, + maxWidth: CGFloat, + bold: Bool = false, + fontSize: CGFloat = FCPXMLReportPDFStyle.bodyFontSize + ) -> String { + let usableWidth = maxWidth - (FCPXMLReportPDFStyle.cellPadding * 2) + guard usableWidth > 4 else { return "" } + guard measuredWidth(text, bold: bold, fontSize: fontSize) > usableWidth else { return text } + + let ellipsis = "…" + var trimmed = text + while !trimmed.isEmpty { + trimmed.removeLast() + let candidate = trimmed + ellipsis + if measuredWidth(candidate, bold: bold, fontSize: fontSize) <= usableWidth { + return candidate + } + } + + return ellipsis + } + + static func wrappedLines( + _ text: String, + maxWidth: CGFloat, + bold: Bool = false, + fontSize: CGFloat = FCPXMLReportPDFStyle.bodyFontSize + ) -> [String] { + let normalized = text + .replacingOccurrences(of: "\n", with: " ") + .split(separator: " ") + .map(String.init) + + guard !normalized.isEmpty else { return [] } + + var lines: [String] = [] + var current = normalized[0] + + for word in normalized.dropFirst() { + let candidate = current + " " + word + if measuredWidth(candidate, bold: bold, fontSize: fontSize) <= maxWidth { + current = candidate + } else { + lines.append(current) + current = word + } + } + + lines.append(current) + return lines + } + + static func naturalColumnWidths(headers: [String], rows: [[String]]) -> [CGFloat] { + headers.enumerated().map { index, header in + var maxWidth = measuredWidth( + header, + bold: true, + fontSize: FCPXMLReportPDFStyle.headerFontSize + ) + + for row in rows { + guard row.indices.contains(index) else { continue } + maxWidth = max( + maxWidth, + measuredWidth( + row[index], + bold: false, + fontSize: FCPXMLReportPDFStyle.bodyFontSize + ) + ) + } + + return maxWidth + (FCPXMLReportPDFStyle.cellPadding * 2) + } + } + + static func measuredWidth( + _ text: String, + bold: Bool, + fontSize: CGFloat = FCPXMLReportPDFStyle.bodyFontSize + ) -> CGFloat { + guard !text.isEmpty else { return 0 } + + let fontName = bold + ? FCPXMLReportPDFStyle.boldFontName + : FCPXMLReportPDFStyle.regularFontName + let font = CTFontCreateWithName(fontName as CFString, fontSize, nil) + let attributes = [kCTFontAttributeName as NSAttributedString.Key: font] + let attributed = NSAttributedString(string: text, attributes: attributes) + let line = CTLineCreateWithAttributedString(attributed) + return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + } +} + +enum FCPXMLReportPDFTableRenderer { + struct TableDrawContext: Sendable { + var pageTitle: String + var sheetColorIndex: Int + var contentHeading: String? + var recordsSectionStart: Bool + var columnPart: Int? + var columnPartCount: Int? + } + + static func drawTable( + on canvas: FCPXMLReportPDFCanvas.Builder, + context: TableDrawContext, + headers: [String], + rows: [[String]], + rowTextColor: CGColor = FCPXMLReportPDFStyle.textColor, + rowTextColorForRow: ((Int, [String]) -> CGColor)? = nil + ) { + guard !headers.isEmpty else { return } + + let prepared = FCPXMLReportPDFTableLayout.preparePaginatedTable( + headers: headers, + rows: rows, + contentWidth: FCPXMLReportPDFStyle.contentWidth + ) + let chunks = FCPXMLReportPDFTableLayout.columnChunks( + headers: prepared.headers, + rows: prepared.rows, + contentWidth: FCPXMLReportPDFStyle.contentWidth, + pinnedColumnIndices: prepared.pinnedColumnIndices + ) + let rowsPerPage = FCPXMLReportPDFTableLayout.rowsPerPage() + + for (chunkIndex, chunk) in chunks.enumerated() { + let chunkHeaders = chunk.headers(from: prepared.headers) + let chunkRows = prepared.rows.map { chunk.rowSlice(from: $0) } + let columnPart = chunks.count > 1 ? chunkIndex + 1 : nil + let columnPartCount = chunks.count > 1 ? chunks.count : nil + + guard !chunkRows.isEmpty else { continue } + + var rowOffset = 0 + while rowOffset < chunkRows.count { + let pageRows = Array(chunkRows.dropFirst(rowOffset).prefix(rowsPerPage)) + + canvas.beginContentPage( + pageTitle: context.pageTitle, + sheetColorIndex: context.sheetColorIndex, + contentHeading: rowOffset == 0 ? context.contentHeading : nil, + columnPart: columnPart, + columnPartCount: columnPartCount, + repeatOnContinuation: rowOffset > 0, + recordsSectionStart: rowOffset == 0 && chunkIndex == 0 && context.recordsSectionStart + ) + + canvas.drawTableHeaderRow(headers: chunkHeaders, columnWidths: chunk.widths) + + for (index, row) in pageRows.enumerated() { + let globalRowIndex = rowOffset + index + let sourceRow = prepared.rows[globalRowIndex] + let textColor = rowTextColorForRow?(globalRowIndex, sourceRow) ?? rowTextColor + canvas.drawTableDataRow( + values: row, + columnWidths: chunk.widths, + textColor: textColor + ) + } + + rowOffset += pageRows.count + + if rowOffset < chunkRows.count { + canvas.endContentPage() + } + } + + canvas.endContentPage() + } + } +} diff --git a/Sources/OpenFCPXMLKit/Reporting/Support/FCPXMLReportRowColorPolicy.swift b/Sources/OpenFCPXMLKit/Reporting/Support/FCPXMLReportRowColorPolicy.swift new file mode 100644 index 0000000..afb2141 --- /dev/null +++ b/Sources/OpenFCPXMLKit/Reporting/Support/FCPXMLReportRowColorPolicy.swift @@ -0,0 +1,211 @@ +// +// FCPXMLReportRowColorPolicy.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// Shared workbook row text colour rules for Excel and PDF report export. +// + +import CoreGraphics +import Foundation + +enum FCPXMLReportRowColorPolicy { + /// Workbook sheet context for row text colour when Category is unavailable. + enum Context { + case roleInventory + case keywords + case titlesAndGenerators + case effects + case speedChangeEffects + case transitions + } + + enum Bucket { + case videoOrSRT + case titles + case audio + case gap + + var fontColorHex: String { + switch self { + case .videoOrSRT: + return "#0066FF" + case .titles: + return "#9933FF" + case .audio: + return "#00AA44" + case .gap: + return "#808080" + } + } + } + + static let roleSubroleColumnHeader = "Role ▸ Subrole" + static let categoryColumnHeader = "Category" + + static func bucket( + for roleSubrole: String, + categoryLabel: String? = nil, + context: Context = .roleInventory + ) -> Bucket { + switch context { + case .keywords: + return .videoOrSRT + case .transitions: + return .gap + case .effects, .speedChangeEffects: + return roleBucketFromRoleName(roleSubrole, titlesAsVideo: true) + case .titlesAndGenerators: + return roleBucketFromRoleName(roleSubrole, titlesAsVideo: false) + case .roleInventory: + if let categoryLabel, + let category = FinalCutPro.FCPXML.ReportClipCategory.matchingWorkbookLabel(categoryLabel) + { + if category == .primaryGap { + return .gap + } + if category.isTitleCategory { + return .titles + } + if category.isVideoCategory || category.isCaptionCategory { + return .videoOrSRT + } + if category.isAudioCategory { + return .audio + } + } + + return roleBucketFromRoleName(roleSubrole, titlesAsVideo: false) + } + } + + static func markerFontColorHex( + for markerType: FinalCutPro.FCPXML.MarkerReportType + ) -> String { + switch markerType { + case .standard: + return Bucket.videoOrSRT.fontColorHex + case .incompleteToDo: + return "#FF0000" + case .completedToDo: + return Bucket.audio.fontColorHex + case .chapter: + return "#FF8800" + } + } + + static func cgColor(fromHex hex: String) -> CGColor? { + var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.hasPrefix("#") { + cleaned.removeFirst() + } + + guard cleaned.count == 6, let value = UInt32(cleaned, radix: 16) else { + return nil + } + + let red = CGFloat((value >> 16) & 0xFF) / 255 + let green = CGFloat((value >> 8) & 0xFF) / 255 + let blue = CGFloat(value & 0xFF) / 255 + + return CGColor( + colorSpace: CGColorSpaceCreateDeviceRGB(), + components: [red, green, blue, 1] + ) + } + + static func cgColor(for bucket: Bucket) -> CGColor? { + cgColor(fromHex: bucket.fontColorHex) + } + + static func markerCGColor( + for markerType: FinalCutPro.FCPXML.MarkerReportType + ) -> CGColor? { + cgColor(fromHex: markerFontColorHex(for: markerType)) + } + + static func textColor( + forRowValues values: [String], + headers: [String], + context: Context, + defaultColor: CGColor + ) -> CGColor { + guard let roleColumnIndex = headers.firstIndex(of: roleSubroleColumnHeader), + values.indices.contains(roleColumnIndex) + else { + return defaultColor + } + + let roleValue = values[roleColumnIndex] + var categoryValue: String? + if let categoryColumnIndex = headers.firstIndex(of: categoryColumnHeader), + values.indices.contains(categoryColumnIndex) + { + categoryValue = values[categoryColumnIndex] + } + + return cgColor( + for: bucket( + for: roleValue, + categoryLabel: categoryValue, + context: context + ) + ) ?? defaultColor + } + + private static func roleBucketFromRoleName( + _ roleSubrole: String, + titlesAsVideo: Bool + ) -> Bucket { + if roleSubrole.localizedCaseInsensitiveContains("gap") { + return .gap + } + + let mainRole = mainRoleName(in: roleSubrole).lowercased() + + if mainRole.isEmpty { + return .videoOrSRT + } + + if mainRole == "titles" || mainRole == "title" { + return titlesAsVideo ? .videoOrSRT : .titles + } + + if ["video", "srt", "vfx"].contains(mainRole) || mainRole.hasPrefix("vfx") { + return .videoOrSRT + } + + if ["dialogue", "effects", "music", "atmos", "score komponist", "sound mix"].contains(mainRole) { + return .audio + } + + if roleSubrole.localizedCaseInsensitiveContains("vfx") + || roleSubrole.localizedCaseInsensitiveContains("scanline") + { + return .videoOrSRT + } + + if roleSubrole.localizedCaseInsensitiveContains("title") { + return titlesAsVideo ? .videoOrSRT : .titles + } + + return .videoOrSRT + } + + private static func mainRoleName(in roleSubrole: String) -> String { + let trimmed = roleSubrole.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + if let separatorIndex = trimmed.firstIndex(where: { ["▸", "•", "·"].contains(String($0)) }) { + return String(trimmed[..] [] []", discussion: "https://github.com/TheAcharya/OpenFCPXMLKit", version: packageVersion, @@ -99,6 +99,9 @@ struct OpenFCPXMLKitCLI: ParsableCommand { if reportOptions.report { _ = try reportOptions.resolvedTimecodeFormat() } + if reportOptions.createPDF && !reportOptions.report { + throw ValidationError("--create-pdf requires --report.") + } if general.checkVersion || general.validate { return } @@ -159,6 +162,7 @@ struct OpenFCPXMLKitCLI: ParsableCommand { fcpxmlPath: fcpxmlPath, outputDir: outDir, options: options, + createPDF: reportOptions.createPDF, logger: logger, showProgress: !logOptions.quiet ) diff --git a/Sources/OpenFCPXMLKitCLI/Options/ReportCLIOptions.swift b/Sources/OpenFCPXMLKitCLI/Options/ReportCLIOptions.swift index f7f35b1..b705f32 100644 --- a/Sources/OpenFCPXMLKitCLI/Options/ReportCLIOptions.swift +++ b/Sources/OpenFCPXMLKitCLI/Options/ReportCLIOptions.swift @@ -5,7 +5,7 @@ // // -// REPORT option group for Excel workbook exports. +// REPORT option group for Excel and PDF report exports. // import ArgumentParser @@ -73,9 +73,15 @@ struct ReportCLIOptions: ParsableArguments { ) var reportMediaSummary: Bool = false + @Flag( + name: .customLong("create-pdf"), + help: "Also write a PDF report alongside the Excel workbook (with --report). Includes the same workbook sections, column exclusions, and timecode formatting when present." + ) + var createPDF: Bool = false + @Option( name: .customLong("report-project"), - help: "Project name filter when the FCPXML contains multiple projects." + help: "Timeline name filter: matches a name or a standalone compound-clip / ref-clip name when the document has more than one reportable timeline." ) var reportProject: String? @@ -94,7 +100,7 @@ struct ReportCLIOptions: ParsableArguments { @Option( name: .customLong("exclude-column"), help: """ - Exclude a workbook column from every applicable report sheet (repeatable; with --report). \ + Exclude a report column from every applicable Excel/PDF sheet (repeatable; with --report). \ Case-insensitive names include Row Numbers, Role Subrole, Clip Name, Frame Rate, Reel, \ Metadata (role inventory dynamic metadata keys), and other shared column headers. \ Columns are removed wherever the sheet uses a matching header. @@ -105,7 +111,7 @@ struct ReportCLIOptions: ParsableArguments { @Option( name: .customLong("timecode-format"), help: """ - Timeline time display format for Excel report cells (with --report). \ + Timeline time display format for Excel and PDF report cells (with --report). \ Values: \(FinalCutPro.FCPXML.ReportTimecodeFormat.cliHelpValues). \ Default: HH:MM:SS:FF (SMPTE with frames; semicolon before frames for drop-frame). """ diff --git a/Sources/OpenFCPXMLKitCLI/README.md b/Sources/OpenFCPXMLKitCLI/README.md index cd6babd..1e25ccc 100644 --- a/Sources/OpenFCPXMLKitCLI/README.md +++ b/Sources/OpenFCPXMLKitCLI/README.md @@ -50,6 +50,9 @@ OpenFCPXMLKit-CLI --report --report-full /path/to/project.fcpxmld /path/to/outpu # Partial report: role inventory plus selected optional sheets only OpenFCPXMLKit-CLI --report --report-markers --report-summary --report-media-summary /path/to/project.fcpxmld /path/to/output-dir +# Excel workbook plus PDF (same workbook sections, column exclusions, and timecode formatting) +OpenFCPXMLKit-CLI --report --create-pdf --report-markers --report-summary /path/to/project.fcpxmld /path/to/output-dir + # Exclude roles from role inventory (repeatable; case-insensitive) OpenFCPXMLKit-CLI --report --exclude-role Dialogue --exclude-role "SRT ▸ de-DE" /path/to/project.fcpxmld /path/to/output-dir @@ -84,7 +87,7 @@ OpenFCPXMLKit-CLI --log-level debug --convert-version 1.10 /path/to/project.fcpx OpenFCPXMLKit-CLI --quiet --media-copy /path/to/project.fcpxml /path/to/media ``` -**Validation:** Use only one of `--check-version`, `--convert-version`, `--validate`, `--media-copy`, `--report`, or `--create-project`. When using `--convert-version`, `--media-copy`, or `--report`, or when running the default process, you must provide ``. When using `--create-project`, you must provide `--width`, `--height`, `--rate`, and the output directory as the single positional argument. `--report-full`, REPORT section flags, `--exclude-role`, `--exclude-disabled-clips`, `--exclude-column`, and `--timecode-format` require `--report`. If `--log` is set and the file exists, it must be writable. Invalid `--log-level`, `--project-version` (for create-project), or `--timecode-format` values produce an error. +**Validation:** Use only one of `--check-version`, `--convert-version`, `--validate`, `--media-copy`, `--report`, or `--create-project`. When using `--convert-version`, `--media-copy`, or `--report`, or when running the default process, you must provide ``. When using `--create-project`, you must provide `--width`, `--height`, `--rate`, and the output directory as the single positional argument. `--report-full`, REPORT section flags, `--exclude-role`, `--exclude-disabled-clips`, `--exclude-column`, `--timecode-format`, and `--create-pdf` require `--report`. If `--log` is set and the file exists, it must be writable. Invalid `--log-level`, `--project-version` (for create-project), or `--timecode-format` values produce an error. --- @@ -110,15 +113,16 @@ OpenFCPXMLKit-CLI --quiet --media-copy /path/to/project.fcpxml /path/to/media | `--report-speed-change-effects` | Include the Speed Change Effects sheet (requires `--report`). | | `--report-summary` | Include the Summary sheet (project metrics and role-duration totals; requires `--report`). | | `--report-media-summary` | Include the Media Summary sheet (missing media file paths; requires `--report`). | +| `--create-pdf` | Also write a PDF report alongside the Excel workbook (requires `--report`). Includes the same workbook sections, column exclusions, and timecode formatting when present in the report. Writes `{project-or-clip-name}.pdf` to output-dir; prints the PDF path to stdout after the `.xlsx` path. | | `--report-project ` | Timeline name filter: matches a `` name or a standalone compound-clip / `ref-clip` name when the document has more than one reportable timeline. | | `--exclude-role ` | Exclude a role or subrole from role inventory (repeatable). Excluding a main role also excludes its subroles. Case-insensitive. | | `--exclude-disabled-clips` | Omit disabled clips (`enabled="0"`) from all timeline-based report sections (requires `--report`). | -| `--exclude-column ` | Exclude a workbook column from every applicable report sheet (repeatable; requires `--report`). Case-insensitive; see [19 — Reporting](../../Documentation/Manual/19-Reporting.md#column-exclusion) for accepted names. | -| `--timecode-format ` | Timeline time display format for Excel report cells (requires `--report`). Values: `HH:MM:SS:FF` (default; SMPTE with frames; `;` before frames for drop-frame), `Frames`, `Feet+Frames`, `HH:MM:SS`. Non-default formats append a suffix to timecode column headers (e.g. `Timeline In (frames)`). See [19 — Reporting](../../Documentation/Manual/19-Reporting.md#timecode-display-format). | +| `--exclude-column ` | Exclude a report column from every applicable Excel/PDF sheet (repeatable; requires `--report`). Case-insensitive; see [19 — Reporting](../../Documentation/Manual/19-Reporting.md#column-exclusion) for accepted names. | +| `--timecode-format ` | Timeline time display format for Excel and PDF report cells (requires `--report`). Values: `HH:MM:SS:FF` (default; SMPTE with frames; `;` before frames for drop-frame), `Frames`, `Feet+Frames`, `HH:MM:SS`. Non-default formats append a suffix to timecode column headers (e.g. `Timeline In (frames)`). See [19 — Reporting](../../Documentation/Manual/19-Reporting.md#timecode-display-format). | When `--report` is used without `--report-full` or section flags, the CLI exports role inventory only. Use `--report-full` for every optional sheet, or set individual `--report-*` section flags for a partial export (role inventory is always included). `--report-full` takes precedence when combined with section flags. -Build progress follows **product / workbook order** (Selected Roles Inventory → Markers → Keywords → Titles & Generators → Transitions → Video & Audio Effects → Speed Change Effects → Summary → Media Summary). See [19 — Progress callbacks](../../Documentation/Manual/19-Reporting.md#progress-callbacks). +Build progress follows **product / workbook order** (Selected Roles Inventory → Markers → Keywords → Titles & Generators → Transitions → Video & Audio Effects → Speed Change Effects → Summary → Media Summary), then **Saving workbook**, and **Saving PDF** when `--create-pdf` is set. See [19 — Progress callbacks](../../Documentation/Manual/19-Reporting.md#progress-callbacks). --- @@ -145,7 +149,7 @@ Log messages include parsing, version conversion, validation, save, and media ex | `Commands/ConvertVersion/` | Implements `--convert-version`: loads FCPXML, converts to target version (1.5–1.14), saves to output-dir as .fcpxmld (default) or .fcpxml per `--extension-type`; 1.5–1.9 always .fcpxml. | | `Commands/Validate/` | Implements `--validate`: loads FCPXML/FCPXMLD and runs robust validation (semantic + DTD). | | `Commands/ExtractMedia/` | Implements `--media-copy`: loads FCPXML/FCPXMLD and copies all referenced media files to output-dir. | -| `Commands/ExportReport/` | Implements `--report`: loads FCPXML/FCPXMLD, builds report sections, and writes an `.xlsx` workbook to output-dir. | +| `Commands/ExportReport/` | Implements `--report`: loads FCPXML/FCPXMLD, builds report sections, writes an `.xlsx` workbook to output-dir, and optionally a `.pdf` when `--create-pdf` is set (same built `Report`; section/column/timecode options apply to both). | | `Commands/CreateProject/` | Implements `--create-project`: creates an empty FCPXML project with given width, height, frame rate, and version; runs DTD validation before writing; outputs FCP-style document (DOCTYPE, colorSpace, default smart collections). | | `Options/TimelineOptions.swift` | **TIMELINE** option group: `--create-project`, `--width`, `--height`, `--rate`, `--project-version`. | | `Generated/` | Generated source; `EmbeddedDTDs.swift` contains hardcoded DTD data (from `GenerateEmbeddedDTDs`). | diff --git a/Tests/ExcelReportTest/ExcelReportExportTests.swift b/Tests/ExcelReportTest/ExcelReportExportTests.swift index 83e5fef..1eabea8 100644 --- a/Tests/ExcelReportTest/ExcelReportExportTests.swift +++ b/Tests/ExcelReportTest/ExcelReportExportTests.swift @@ -53,6 +53,26 @@ final class ExcelReportExportTests: XCTestCase, @unchecked Sendable { XCTAssertNotNil(fullReport.mediaSummary) } + /// Writes `Output/OFK-Default.pdf` from the role-inventory report for manual PDF review. + func testExportDefaultRoleInventoryPDF() async throws { + let fixtureURL = try ExcelReportFixture.requireFixtureURL() + let outputDir = ExcelReportFixture.outputDirectoryURL() + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + + let report = try await loadReport(options: .roleInventoryOnly, fixtureURL: fixtureURL) + let outputURL = outputDir.appendingPathComponent("OFK-Default.pdf") + + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } + + try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: outputURL) + + let data = try Data(contentsOf: outputURL) + XCTAssertEqual(String(data: data.prefix(4), encoding: .ascii), "%PDF") + XCTAssertGreaterThan(data.count, 5_000, "Role inventory PDF should contain readable multi-page output") + } + @MainActor private func writeWorkbook( _ report: FinalCutPro.FCPXML.Report, diff --git a/Tests/ExcelReportTest/Output/README.md b/Tests/ExcelReportTest/Output/README.md index 046ed22..b4fa17e 100644 --- a/Tests/ExcelReportTest/Output/README.md +++ b/Tests/ExcelReportTest/Output/README.md @@ -1,6 +1,6 @@ -# Excel report test output +# Excel and PDF report test output -This folder holds **generated** `.xlsx` workbooks from the `ExcelReportTest` target. It is gitignored; files here are produced on your machine when you run the export tests. +This folder holds **generated** `.xlsx` workbooks and `.pdf` reports from the `ExcelReportTest` target. It is gitignored; files here are produced on your machine when you run the export tests. --- @@ -10,6 +10,7 @@ This folder holds **generated** `.xlsx` workbooks from the `ExcelReportTest` tar |------|---------------|-------------| | **`OFK-Default.xlsx`** | `ReportOptions.roleInventoryOnly` | Selected Roles Inventory sheet and per-role inventory tabs only (same as CLI `--report` without `--report-full`) | | **`OFK-Full.xlsx`** | `ReportOptions.full` | Default sheets plus Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, Summary, and Media Summary (default timecode format `HH:MM:SS:FF`; use CLI `--timecode-format` for other modes) | +| **`OFK-Default.pdf`** | `ReportOptions.roleInventoryOnly` | Role-inventory PDF with cover page, table of contents, and section content pages (same as CLI `--report --create-pdf` without `--report-full`) | Each test run **overwrites** these files if they already exist. @@ -17,7 +18,7 @@ Each test run **overwrites** these files if they already exist. ## Source FCPXML -Workbooks are built from whatever fixture `ExcelReportFixture` resolves: +Reports are built from whatever fixture `ExcelReportFixture` resolves: - A **`.fcpxmld`** bundle (directory with `Info.fcpxml`), or - A **`.fcpxml`** single file @@ -36,12 +37,18 @@ From the repository root: swift test --filter ExcelReportExportTests ``` -Then open `OFK-Default.xlsx` or `OFK-Full.xlsx` in Excel, Numbers, or your diff tool and compare against a reference export (e.g. Production's Best Friend). +Then open `OFK-Default.xlsx`, `OFK-Full.xlsx`, or `OFK-Default.pdf` in Excel, Preview, or your diff tool and compare against a reference export. + +For a full PDF on a real fixture (named after the project), use the CLI: + +```bash +OpenFCPXMLKit-CLI --report --report-full --create-pdf /path/to/fixture.fcpxmld /path/to/output-dir +``` --- ## Notes -- Output file names are fixed (`OFK-Default.xlsx`, `OFK-Full.xlsx`) so paths stay stable for scripts and future parity tests. +- Output file names are fixed (`OFK-Default.xlsx`, `OFK-Full.xlsx`, `OFK-Default.pdf`) so paths stay stable for scripts and future parity tests. - The CLI names files after the **project or compound-clip name** inside the FCPXML; test output uses these constant names instead. -- Do not commit large generated workbooks unless you intentionally add golden files for regression testing. +- Do not commit large generated workbooks or PDFs unless you intentionally add golden files for regression testing. diff --git a/Tests/ExcelReportTest/README.md b/Tests/ExcelReportTest/README.md index cd9ab2a..2b32792 100644 --- a/Tests/ExcelReportTest/README.md +++ b/Tests/ExcelReportTest/README.md @@ -1,12 +1,12 @@ -# Excel report integration tests +# Excel and PDF report integration tests -Optional integration tests that build real `.xlsx` workbooks from a local FCPXML fixture. Use a normal **project** export or a standalone **compound-clip** export (event `ref-clip` with no ``). Use this target when you want to compare OpenFCPXMLKit output against reference exports without running the CLI each time. +Optional integration tests that build real `.xlsx` workbooks and `.pdf` reports from a local FCPXML fixture. Use a normal **project** export or a standalone **compound-clip** export (event `ref-clip` with no ``). Use this target when you want to compare OpenFCPXMLKit output against reference exports without running the CLI each time. **Target:** `ExcelReportTest` **Depends on:** `OpenFCPXMLKit`, `XLKit` -**Tests:** 1 (`ExcelReportExportTests`) +**Tests:** 2 (`ExcelReportExportTests`) -Unit-level reporting behaviour (column layout, column exclusion, disabled-clip filtering, timecode formats / DF·NDF, format-aware headers, build-phase order, workbook cell formatting and sheet-specific colour rules, **standalone compound-clip timeline resolution**) lives in **`OpenFCPXMLKitTests`** — see [Tests/README.md](../README.md#reporting--excel-export) (`FCPXMLCompoundClipReportTests` and related files). +Unit-level reporting behaviour (column layout, column exclusion, disabled-clip filtering, timecode formats / DF·NDF, format-aware headers, build-phase order, workbook cell formatting, PDF cover/TOC/pagination, shared row colours, **standalone compound-clip timeline resolution**) lives in **`OpenFCPXMLKitTests`** — see [Tests/README.md](../README.md#reporting--excelpdf-export) (`FCPXMLCompoundClipReportTests`, `FCPXMLReportPDFExportTests`, and related files). --- @@ -54,28 +54,31 @@ export OFK_REPORTING_FCPXML_BUNDLE="/path/to/Project.fcpxml" ## Generated output -Running the export test writes workbooks to **`Output/`** (also gitignored): +Running the export tests writes workbooks and a sample PDF to **`Output/`** (also gitignored): | File | Report preset | CLI equivalent | Contents | |------|---------------|----------------|----------| | `Output/OFK-Default.xlsx` | `ReportOptions.roleInventoryOnly` | `OpenFCPXMLKit-CLI --report ` | **Selected Roles Inventory** + per-role sheets (23 fixed columns + Row + dynamic metadata keys) | | `Output/OFK-Full.xlsx` | `ReportOptions.full` | `OpenFCPXMLKit-CLI --report --report-full ` | Default sheets plus Markers, Keywords, Titles & Generators, Transitions, Video & Audio Effects, Speed Change Effects, **Summary** (project title header + black data rows), and **Media Summary** (red missing-media paths) | +| `Output/OFK-Default.pdf` | `ReportOptions.roleInventoryOnly` | `OpenFCPXMLKit-CLI --report --create-pdf ` | Role-inventory PDF with cover page, TOC, and per-sheet tinted content pages | -Cell colours, header styling, and section-sheet colour rules are covered by **`FCPXMLReportExcelExportTests`** in `OpenFCPXMLKitTests`. This integration target checks that a real fixture produces complete workbooks; open `OFK-Full.xlsx` locally to compare layout and formatting against a reference export if you maintain one. +Cell colours, header styling, and section-sheet colour rules are covered by **`FCPXMLReportExcelExportTests`** and **`FCPXMLReportPDFExportTests`** in `OpenFCPXMLKitTests`. This integration target checks that a real fixture produces complete workbooks and a readable PDF; open `OFK-Full.xlsx` or a full `--create-pdf` export locally to compare layout against a reference export if you maintain one. See [Output/README.md](Output/README.md) for details on that folder. `testExportDefaultAndFullWorkbooks` asserts that the default export includes only role inventory, and that the full export includes every optional section (`summary`, `mediaSummary`, markers, keywords, titles, transitions, effects, speed-change effects). +`testExportDefaultRoleInventoryPDF` writes `OFK-Default.pdf` and asserts a valid `%PDF` header and minimum size. + --- ## CLI parity -The integration target mirrors the two most common CLI flows. For filtered exports, use the CLI or build reports in code: +The integration target mirrors common CLI flows. For filtered or full PDF exports, use the CLI or build reports in code: ```bash -# Omit disabled clips, columns, and use frame-count timecode (not covered by ExcelReportExportTests) -OpenFCPXMLKit-CLI --report --report-full \ +# Excel + PDF with the same report configuration +OpenFCPXMLKit-CLI --report --report-full --create-pdf \ --exclude-disabled-clips \ --exclude-column Reel \ --exclude-column Metadata \ @@ -93,23 +96,25 @@ let report = try await fcpxml.buildReport(options: options) { phase in // phase order matches GUI / workbook: inventory first _ = phases } +try await FinalCutPro.FCPXML.ReportExcelExport.export(report, to: xlsxURL) +try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: pdfURL) ``` -See [Documentation/Manual/19-Reporting.md](../../Documentation/Manual/19-Reporting.md) for the full API (`ReportTimecodeFormat`, progress phases, column exclusion). +See [Documentation/Manual/19-Reporting.md](../../Documentation/Manual/19-Reporting.md) for the full API (`ReportPDFExport`, `ReportTimecodeFormat`, progress phases, column exclusion). --- ## Running tests ```bash -# Export Default + Full workbooks (requires a local fixture) +# Export Default + Full workbooks and Default PDF (requires a local fixture) swift test --filter ExcelReportExportTests # Entire Excel report target swift test --filter ExcelReportTest ``` -First run on a large fixture can take ~1–2 minutes (report build + XLKit save). +First run on a large fixture can take ~1–2 minutes (report build + XLKit save; PDF export is additional). --- @@ -118,8 +123,8 @@ First run on a large fixture can take ~1–2 minutes (report build + XLKit save) | File | Purpose | |------|---------| | `ExcelReportFixture.swift` | Resolves fixture URL, `mediaBaseURL`, and `Output/` path; defines output file names | -| `ExcelReportExportTests.swift` | Builds and writes `OFK-Default.xlsx` and `OFK-Full.xlsx` | -| `Output/` | Generated workbooks (created by tests; gitignored) | +| `ExcelReportExportTests.swift` | Builds and writes `OFK-Default.xlsx`, `OFK-Full.xlsx`, and `OFK-Default.pdf` | +| `Output/` | Generated workbooks and PDFs (created by tests; gitignored) | --- @@ -143,8 +148,8 @@ Put new test classes in this directory. Reuse `ExcelReportFixture.requireFixture Good candidates for this target: -- Golden-file parity against a reference `.xlsx` (keep references out of git if large or licensed) +- Golden-file parity against a reference `.xlsx` or `.pdf` (keep references out of git if large or licensed) - Filtered exports (`excludeDisabledClips`, `excludedColumns`, `excludedRoles`) written to additional `Output/` files -- Sheet/column-count or cell-format smoke checks on a known fixture +- Full `--create-pdf` smoke checks on a known fixture -Prefer **`OpenFCPXMLKitTests`** for logic that does not need a full local fixture (column resolution, layout, `ReportTimecodeFormat`, `ReportBuildPhase` order, synthetic workbook structure, Summary/Media Summary/Keywords/Effects colour rules, compound-clip-only timeline discovery). +Prefer **`OpenFCPXMLKitTests`** for logic that does not need a full local fixture (column resolution, layout, `ReportTimecodeFormat`, `ReportBuildPhase` order, synthetic workbook/PDF structure, Summary/Media Summary/Keywords/Effects colour rules, compound-clip-only timeline discovery). diff --git a/Tests/OpenFCPXMLKitTests/FCPXMLCompoundClipReportTests.swift b/Tests/OpenFCPXMLKitTests/FCPXMLCompoundClipReportTests.swift index 4668ac1..8e158a5 100644 --- a/Tests/OpenFCPXMLKitTests/FCPXMLCompoundClipReportTests.swift +++ b/Tests/OpenFCPXMLKitTests/FCPXMLCompoundClipReportTests.swift @@ -7,12 +7,14 @@ // // Excel reporting for standalone compound-clip FCPXML (event ref-clip, no project). +// Inline fixtures only (no bundled samples or DTD validation), so these tests run on +// iOS 26+ as well as macOS and cover compound-clip discovery via the AEXML backend. // import XCTest @testable import OpenFCPXMLKit -@available(macOS 26.0, *) +@available(macOS 26.0, iOS 26.0, *) final class FCPXMLCompoundClipReportTests: XCTestCase { private static let compoundClipName = "Standalone Compound Clip" @@ -93,7 +95,13 @@ final class FCPXMLCompoundClipReportTests: XCTestCase { FCPXMLReportingReportTestSupport.assertValidTimecode( report.summary?.projectSummary?.duration ?? "" ) - XCTAssertFalse(report.summary?.roleDurations.isEmpty ?? true) + + let roleDurations = report.summary?.roleDurations ?? [] + XCTAssertFalse(roleDurations.isEmpty) + XCTAssertTrue( + roleDurations.contains(where: { $0.roleSubrole.localizedCaseInsensitiveContains("dialogue") }), + "Expected dialogue role in summary role durations; got \(Set(roleDurations.map(\.roleSubrole)))" + ) } func testProjectNameFilterMatchesCompoundClipName() async throws { diff --git a/Tests/OpenFCPXMLKitTests/FCPXMLReportPDFExportTests.swift b/Tests/OpenFCPXMLKitTests/FCPXMLReportPDFExportTests.swift new file mode 100644 index 0000000..6cfcabe --- /dev/null +++ b/Tests/OpenFCPXMLKitTests/FCPXMLReportPDFExportTests.swift @@ -0,0 +1,467 @@ +// +// FCPXMLReportPDFExportTests.swift +// OpenFCPXMLKit • https://github.com/TheAcharya/OpenFCPXMLKit +// © 2026 • Licensed under MIT License +// + +// +// PDF export tests for structured FCPXML reports. +// + +import Foundation +import OpenFCPXMLKit +import XCTest +#if canImport(PDFKit) +import PDFKit +#endif + +@available(macOS 26.0, *) +final class FCPXMLReportPDFExportTests: XCTestCase, @unchecked Sendable { + + func testMakePDFDataFromSyntheticMarkersReportStartsWithPDFHeader() throws { + let report = FinalCutPro.FCPXML.Report( + projectName: "Test Project", + eventName: "Test Event", + markers: FinalCutPro.FCPXML.MarkersReportSection(rows: [ + FinalCutPro.FCPXML.MarkerReportRow( + markerName: "Scene 1", + type: .standard, + notes: "Note", + position: "00:00:10:00", + clipName: "Clip A", + roleSubrole: "Video", + reel: "A001", + scene: "1", + sourcePosition: "00:00:05:00" + ) + ]) + ) + + let data = try FinalCutPro.FCPXML.ReportPDFExport.makePDFData(from: report) + XCTAssertGreaterThan(data.count, 0) + + let prefix = String(data: data.prefix(4), encoding: .ascii) + XCTAssertEqual(prefix, "%PDF") + + #if canImport(PDFKit) + guard let document = PDFDocument(data: data) else { + XCTFail("Expected valid PDF document") + return + } + + let markersPageIndex = document.pageCount > 2 ? 2 : 1 + guard let markersPageText = document.page(at: markersPageIndex)?.string else { + XCTFail("Expected markers content page") + return + } + + for header in FinalCutPro.FCPXML.MarkerReportRow.columnHeaders { + XCTAssertTrue( + markersPageText.contains(header), + "Expected table header \"\(header)\" to be visible in PDF text extraction" + ) + } + #endif + } + + func testExportSyntheticSummaryReportWritesPDFFile() throws { + let report = FinalCutPro.FCPXML.Report( + projectName: "Summary Project", + summary: FinalCutPro.FCPXML.SummaryReportSection( + projectSummary: FinalCutPro.FCPXML.ProjectSummary( + title: "Summary Project", + duration: "00:05:00:00", + resolution: "1920x1080", + frameRate: "25 fps", + audioSampleRate: "48 kHz" + ), + roleDurations: [ + FinalCutPro.FCPXML.SummaryRoleDurationRow( + roleSubrole: "Dialogue", + estimatedTotal: "00:02:00:00", + percentOfTotal: 40 + ) + ] + ) + ) + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("OFK-Summary-\(UUID().uuidString).pdf") + defer { try? FileManager.default.removeItem(at: outputURL) } + + try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: outputURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path)) + + let data = try Data(contentsOf: outputURL) + XCTAssertEqual(String(data: data.prefix(4), encoding: .ascii), "%PDF") + } + + func testExportSyntheticMediaSummaryReportWritesPDFFile() throws { + let report = FinalCutPro.FCPXML.Report( + projectName: "Media Project", + mediaSummary: FinalCutPro.FCPXML.MediaSummaryReportSection( + missingMediaPaths: [ + "/Volumes/Media/clip001.mov", + "/Volumes/Media/clip002.wav" + ] + ) + ) + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("OFK-MediaSummary-\(UUID().uuidString).pdf") + defer { try? FileManager.default.removeItem(at: outputURL) } + + try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: outputURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path)) + } + + func testExportMarkersReportWritesPDFFileFromFixture() async throws { + let fcpxml = try FCPXMLReportingReportFixture.loadFCPXML() + + let report = try await fcpxml.buildReport( + options: try FCPXMLReportingReportFixture.reportOptions { + $0.includeMarkers = true + } + ) + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("OFK-Markers-\(UUID().uuidString).pdf") + defer { try? FileManager.default.removeItem(at: outputURL) } + + try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: outputURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path)) + + let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path) + let fileSize = attributes[.size] as? NSNumber + XCTAssertGreaterThan(fileSize?.intValue ?? 0, 0) + } + + func testExportMarkersReportFromBasicMarkersSampleWritesPDFFile() async throws { + let fcpxml = try loadFCPXMLSample(named: FCPXMLSampleName.basicMarkers.rawValue) + + var options = FinalCutPro.FCPXML.ReportOptions( + includeMarkers: true, + includeRoleInventory: false + ) + options.projectName = fcpxml.allProjects().first?.name + + let report = try await fcpxml.buildReport(options: options) + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("OFK-BasicMarkers-\(UUID().uuidString).pdf") + defer { try? FileManager.default.removeItem(at: outputURL) } + + try FinalCutPro.FCPXML.ReportPDFExport.export(report, to: outputURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path)) + let data = try Data(contentsOf: outputURL) + XCTAssertEqual(String(data: data.prefix(4), encoding: .ascii), "%PDF") + } + + func testWideMarkersTableProducesMultiPagePDF() throws { + let rows = (1...40).map { index in + FinalCutPro.FCPXML.MarkerReportRow( + markerName: "Marker \(index)", + type: .standard, + notes: "Note for marker \(index) with additional detail", + position: "00:00:\(String(format: "%02d", index % 60)):00", + clipName: "Clip \(index)", + roleSubrole: "Dialogue ▸ Main", + reel: "A\(index)", + scene: "\(index)", + sourcePosition: "00:00:\(String(format: "%02d", index % 60)):00" + ) + } + + let report = FinalCutPro.FCPXML.Report( + projectName: "Wide Table", + markers: FinalCutPro.FCPXML.MarkersReportSection(rows: rows) + ) + + let data = try FinalCutPro.FCPXML.ReportPDFExport.makePDFData(from: report) + XCTAssertGreaterThan(data.count, 4_000, "Wide tables should produce multi-page PDF output") + + #if canImport(PDFKit) + guard let document = PDFDocument(data: data) else { + XCTFail("Expected valid PDF document") + return + } + + var foundRowOnContinuationPage = false + for pageIndex in 1.. 2 ? 2 : 1 + XCTAssertTrue(document.page(at: contentPageIndex)?.string?.contains(branding) == true) + #endif + } + + func testRoleInventoryReportIncludesTableOfContentsWithoutLinks() throws { + let clipRow = FinalCutPro.FCPXML.RoleClipReportRow( + roleSubrole: "Dialogue", + clipName: "Clip A", + category: "Primary clip", + enabled: "Yes", + timelineIn: "00:00:00:00", + timelineOut: "00:00:05:00", + clipDuration: "00:00:05:00", + sourceIn: "01:00:00:00", + sourceOut: "01:00:05:00", + sourceDuration: "00:00:05:00" + ) + + let report = FinalCutPro.FCPXML.Report( + projectName: "TOC Project", + markers: FinalCutPro.FCPXML.MarkersReportSection(rows: [ + FinalCutPro.FCPXML.MarkerReportRow( + markerName: "Marker 1", + type: .standard, + notes: "Note", + position: "00:00:10:00", + clipName: "Clip A", + roleSubrole: "Video", + reel: "A001", + scene: "1", + sourcePosition: "00:00:05:00" + ) + ]), + roleInventory: FinalCutPro.FCPXML.RoleInventoryReportSection( + selectedRoles: [clipRow], + roleSheets: [ + FinalCutPro.FCPXML.RoleSheet( + sheetName: "Dialogue", + rows: [clipRow] + ) + ] + ) + ) + + let data = try FinalCutPro.FCPXML.ReportPDFExport.makePDFData(from: report) + + #if canImport(PDFKit) + guard let document = PDFDocument(data: data) else { + XCTFail("Expected valid PDF document") + return + } + + guard let tocPage = document.page(at: 1), let tocText = tocPage.string else { + XCTFail("Expected table of contents page") + return + } + + XCTAssertTrue(tocText.contains("Table of Contents")) + XCTAssertTrue(tocText.contains("Sheet")) + XCTAssertTrue(tocText.contains("Page")) + XCTAssertTrue(tocText.contains("Selected Roles Inventory")) + XCTAssertTrue(tocText.contains("Dialogue")) + XCTAssertTrue(tocText.contains("Markers")) + XCTAssertFalse(tocText.contains("....")) + #endif + } + + func testFullReportIncludesAllWorkbookSectionsInPDF() throws { + let effectRow = FinalCutPro.FCPXML.EffectReportRow( + effect: "Blur", + settings: "Amount 50", + enabled: "Yes", + isApple: "No", + clipName: "Clip A", + roleSubrole: "Video", + timelineIn: "00:00:01:00", + timelineOut: "00:00:05:00" + ) + + let report = FinalCutPro.FCPXML.Report( + projectName: "Full Report", + markers: FinalCutPro.FCPXML.MarkersReportSection(rows: [ + FinalCutPro.FCPXML.MarkerReportRow( + markerName: "Marker 1", + type: .standard, + notes: "Note", + position: "00:00:10:00", + clipName: "Clip A", + roleSubrole: "Video", + reel: "A001", + scene: "1", + sourcePosition: "00:00:05:00" + ) + ]), + keywords: FinalCutPro.FCPXML.KeywordsReportSection(rows: [ + FinalCutPro.FCPXML.KeywordReportRow( + keyword: "Hero", + notes: "Note", + timelineIn: "00:00:01:00", + timelineOut: "00:00:05:00", + duration: "00:00:04:00", + clipName: "Clip A", + roleSubrole: "Video", + reel: "A001", + scene: "1" + ) + ]), + titlesAndGenerators: FinalCutPro.FCPXML.TitlesReportSection(rows: [ + FinalCutPro.FCPXML.TitleReportRow( + clipName: "Lower Third", + enabled: "Yes", + isApple: "Yes", + roleSubrole: "Titles", + timelineIn: "00:00:01:00", + timelineOut: "00:00:05:00", + duration: "00:00:04:00", + font: "Helvetica", + titleText: "Hello" + ) + ]), + transitions: FinalCutPro.FCPXML.TransitionsReportSection(rows: [ + FinalCutPro.FCPXML.TransitionReportRow( + transition: "Cross Dissolve", + category: "Video", + isApple: "Yes", + timelineIn: "00:00:01:00", + timelineOut: "00:00:02:00", + duration: "00:00:01:00" + ) + ]), + effects: FinalCutPro.FCPXML.EffectsReportSection(rows: [effectRow]), + speedChangeEffects: FinalCutPro.FCPXML.SpeedChangeEffectsReportSection(rows: [effectRow]), + summary: FinalCutPro.FCPXML.SummaryReportSection( + projectSummary: FinalCutPro.FCPXML.ProjectSummary( + title: "Full Report", + duration: "00:05:00:00", + resolution: "1920x1080", + frameRate: "25 fps", + audioSampleRate: "48 kHz" + ) + ), + mediaSummary: FinalCutPro.FCPXML.MediaSummaryReportSection( + missingMediaPaths: ["/Volumes/Media/missing.mov"] + ), + roleInventory: FinalCutPro.FCPXML.RoleInventoryReportSection( + selectedRoles: [ + FinalCutPro.FCPXML.RoleClipReportRow( + roleSubrole: "Dialogue", + clipName: "Clip A", + category: "Primary clip", + enabled: "Yes", + timelineIn: "00:00:00:00", + timelineOut: "00:00:05:00", + clipDuration: "00:00:05:00", + sourceIn: "01:00:00:00", + sourceOut: "01:00:05:00", + sourceDuration: "00:00:05:00" + ) + ] + ) + ) + + let data = try FinalCutPro.FCPXML.ReportPDFExport.makePDFData(from: report) + + #if canImport(PDFKit) + guard let document = PDFDocument(data: data), + let tocText = document.page(at: 1)?.string + else { + XCTFail("Expected table of contents page") + return + } + + let expectedSheets = [ + FinalCutPro.FCPXML.RoleInventoryReportSection.defaultSheetName, + FinalCutPro.FCPXML.MarkersReportSection.defaultSheetName, + FinalCutPro.FCPXML.KeywordsReportSection.defaultSheetName, + FinalCutPro.FCPXML.TitlesReportSection.defaultSheetName, + FinalCutPro.FCPXML.TransitionsReportSection.defaultSheetName, + FinalCutPro.FCPXML.EffectsReportSection.defaultSheetName, + FinalCutPro.FCPXML.SpeedChangeEffectsReportSection.defaultSheetName, + FinalCutPro.FCPXML.SummaryReportSection.defaultSheetName, + FinalCutPro.FCPXML.MediaSummaryReportSection.defaultSheetName, + ] + + for sheet in expectedSheets { + XCTAssertTrue(tocText.contains(sheet), "Expected TOC to include \(sheet)") + } + + var combinedContent = "" + for pageIndex in 2../dev/null | grep -c '\.' # 933 -swift test --list-tests 2>/dev/null | grep -c 'OpenFCPXMLKitTests\.' # 932 -swift test --list-tests 2>/dev/null | grep -c 'ExcelReportTest\.' # 1 +swift test --list-tests 2>/dev/null | grep -c '\.' # 944 +swift test --list-tests 2>/dev/null | grep -c 'OpenFCPXMLKitTests\.' # 942 +swift test --list-tests 2>/dev/null | grep -c 'ExcelReportTest\.' # 2 ``` ### Xcode @@ -264,7 +265,7 @@ Tests are discovered automatically by Swift PM. Run `swift test` in an environme - **FCPXMLCMTimeCodableTests** — CMTime encode/decode as FCPXML time strings; round-trip; edge cases. - **FCPXMLCollectionTests** — CollectionFolder, KeywordCollection; nested folders; Codable. -**Reporting & Excel export** (see [19 — Reporting & Excel Export](../Documentation/Manual/19-Reporting.md)) +**Reporting & Excel/PDF export** (see [19 — Reporting, Excel & PDF Export](../Documentation/Manual/19-Reporting.md)) - **FCPXMLCompoundClipReportTests** — Standalone compound-clip FCPXML (event `ref-clip` → `media`/`sequence`, no ``): `allReportTimelineSources()`, role inventory / markers / summary via `buildReport`, project-name filter, and regression that normal project reports still resolve. - **FCPXMLRoleInventoryReportTests** — Role inventory section: Selected Roles Inventory rows and per-role sheets, categories, columns. @@ -276,6 +277,7 @@ Tests are discovered automatically by Swift PM. Run `swift test` in an environme - **FCPXMLEffectsReportTests** / **FCPXMLSpeedChangeEffectsReportTests** — Video & Audio Effects and Speed Change Effects rows. - **FCPXMLSummaryReportTests** — Summary sheet: project metrics, per-role duration rows, percentage of total; Media Summary sheet: missing media paths; `.summaryOnly` and `.mediaSummaryOnly` presets. - **FCPXMLReportExcelExportTests** — XLKit workbook export: Title Case sheet names, sheet ordering, sheet-name sanitisation, Media Summary sheet (red missing-media paths), Summary sheet (project title header row, black role-duration data, numeric `% of Total` cells), inventory/marker/section-sheet colour rules (role category, marker type, sheet-specific inference for Keywords/Effects/Titles/Transitions), cover sheet styling, black/white table headers. +- **FCPXMLReportPDFExportTests** — CoreGraphics PDF export: `%PDF` header, cover page and custom branding, table of contents for multi-section reports, synthetic section content, wide-table multi-page pagination, full-workbook section parity (all nine sections in TOC/content), markers/media-summary/summary-only exports. - **FCPXMLReportFormattingTests** — Timecode string formats (SMPTE DF/NDF, Frames, Feet+Frames, HH:MM:SS); `compareTimelinePositions` numeric order for Frames and Feet+Frames vs lexicographic string order; role ▸ subrole field formatting, `` handling, channel-ordered role fields. - **FCPXMLReportTimecodeFormatTests** — Integration: DF/NDF sample reports; all four `ReportTimecodeFormat` modes; full-report cell/header shape assertions; workbook header suffixes; Keywords Frames-mode numeric row order. - **FCPXMLReportBuildPhaseTests** — `ReportBuildPhase.enabledPhases(for:)` product order (Selected Roles Inventory first); `onPhaseStarted` callback order matches enabled phases for `.full`. @@ -416,20 +418,20 @@ Document manager tests create documents for **FCPXML 1.5 through 1.14** and asse --- -## 12. Excel report integration tests +## 12. Excel and PDF report integration tests -The **`ExcelReportTest`** target (separate from `OpenFCPXMLKitTests`) builds real `.xlsx` workbooks from a **local** FCPXML fixture (a normal project **or** a standalone compound-clip export). It is optional: without a fixture, tests skip and CI stays green. +The **`ExcelReportTest`** target (separate from `OpenFCPXMLKitTests`) builds real `.xlsx` and `.pdf` reports from a **local** FCPXML fixture (a normal project **or** a standalone compound-clip export). It is optional: without a fixture, tests skip and CI stays green. | Item | Detail | |------|--------| | **Location** | `Tests/ExcelReportTest/` | -| **Test class** | `ExcelReportExportTests` — writes `Output/OFK-Default.xlsx` and `Output/OFK-Full.xlsx` | +| **Test class** | `ExcelReportExportTests` — writes `Output/OFK-Default.xlsx`, `Output/OFK-Full.xlsx`, and `Output/OFK-Default.pdf` | | **Fixture** | `Sample.fcpxmld` / `Sample.fcpxml` in that folder, or `OFK_REPORTING_FCPXML_BUNDLE` env var | | **Run** | `swift test --filter ExcelReportExportTests` | Full setup, output description, and CI notes: **[ExcelReportTest/README.md](ExcelReportTest/README.md)**. -Use this target for end-to-end workbook generation on a real fixture (open `Output/OFK-Full.xlsx` to visually verify sheet layout and cell colours). Standalone compound-clip reporting (no ``) is covered in unit form by **`FCPXMLCompoundClipReportTests`** in `OpenFCPXMLKitTests`. Use other **`OpenFCPXMLKitTests`** reporting files (listed under **Reporting & Excel export** in [§3.2](#32-dedicated-test-files-by-theme)) for unit and integration tests against bundled FCPXML samples and synthetic workbook structure. +Use this target for end-to-end workbook/PDF generation on a real fixture (open `Output/OFK-Full.xlsx` or a CLI-generated `.pdf` to visually verify layout). Standalone compound-clip reporting (no ``) is covered in unit form by **`FCPXMLCompoundClipReportTests`** in `OpenFCPXMLKitTests`. Use **`FCPXMLReportPDFExportTests`** and other **`OpenFCPXMLKitTests`** reporting files (listed under **Reporting & Excel/PDF export** in [§3.2](#32-dedicated-test-files-by-theme)) for unit and integration tests against bundled FCPXML samples and synthetic report structure. --- @@ -473,7 +475,7 @@ Add tests for new behaviour or edge cases; place them in the right file and MARK - **XCTest** (Apple documentation) - **Testing in Xcode** (Apple documentation) - **OpenFCPXMLKit README** (project root) — overview and API usage -- **Documentation/Manual** — full manual; [19 — Reporting & Excel Export](../Documentation/Manual/19-Reporting.md) for report API; [18 — Cross-Platform & iOS](../Documentation/Manual/18-Cross-Platform-iOS.md) for XML abstraction and iOS support +- **Documentation/Manual** — full manual; [19 — Reporting, Excel & PDF Export](../Documentation/Manual/19-Reporting.md) for report API; [18 — Cross-Platform & iOS](../Documentation/Manual/18-Cross-Platform-iOS.md) for XML abstraction and iOS support - **Final Cut Pro XML (FCPXML)** — [fcp.cafe](https://fcp.cafe) for format reference - **SwiftTimecode** (GitHub) — timecode and frame rate types