Skip to content

Commit a02dd3a

Browse files
macmadeclaude
andcommitted
feat: add serialization scaffolding for FITS write support
Lay the non-behavioral groundwork for the upcoming write and serialization support without altering any read behavior. - Introduce FITSFile.cardSize (80) and FITSFile.keywordLength (8) named constants, and replace the previously hardcoded 80/8 literals in FITSProperty, FITSSection and FITSBlock with them. - Add FITSSerializationOptions, an OptionSet mirroring FITSParsingOptions with .strict/.lenient presets. Concrete flags are deferred until the writer needs them. - Add invalidValueForSerialization, cannotSerialize and cannotWriteFile cases to FITSError for the write path. - Register the new source and test files in the Xcode project; the Swift package picks them up via directory globbing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2dd5db7 commit a02dd3a

12 files changed

Lines changed: 805 additions & 24 deletions

File tree

Docs/agent-plans/active/2026-07-11-write-support/write-support-plan.html

Lines changed: 494 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Write support</title>
7+
<style>
8+
:root {
9+
color-scheme: dark;
10+
--bg: #12141a;
11+
--panel: #1b1e26;
12+
--border: #2b2f3a;
13+
--text: #e6e8ee;
14+
--muted: #9aa0ac;
15+
--accent: #6fa8ff;
16+
}
17+
* { box-sizing: border-box; }
18+
body {
19+
margin: 0;
20+
padding: 2.5rem 1.5rem 4rem;
21+
background: var(--bg);
22+
color: var(--text);
23+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
24+
line-height: 1.6;
25+
}
26+
main {
27+
max-width: 820px;
28+
margin: 0 auto;
29+
}
30+
header.doc {
31+
border-bottom: 1px solid var(--border);
32+
padding-bottom: 1.25rem;
33+
margin-bottom: 2rem;
34+
}
35+
h1 {
36+
margin: 0 0 0.35rem;
37+
font-size: 2rem;
38+
letter-spacing: -0.01em;
39+
}
40+
.generated {
41+
color: var(--muted);
42+
font-size: 0.9rem;
43+
margin: 0;
44+
}
45+
.overview {
46+
color: var(--text);
47+
margin: 1.25rem 0 0;
48+
}
49+
h2.section-title {
50+
font-size: 1.15rem;
51+
text-transform: uppercase;
52+
letter-spacing: 0.08em;
53+
color: var(--muted);
54+
margin: 2.5rem 0 1rem;
55+
}
56+
ol.backlog {
57+
list-style: none;
58+
counter-reset: item;
59+
padding: 0;
60+
margin: 0;
61+
}
62+
ol.backlog > li {
63+
counter-increment: item;
64+
background: var(--panel);
65+
border: 1px solid var(--border);
66+
border-radius: 10px;
67+
padding: 1.25rem 1.4rem;
68+
margin-bottom: 1rem;
69+
}
70+
ol.backlog > li h3 {
71+
margin: 0 0 0.4rem;
72+
font-size: 1.1rem;
73+
display: flex;
74+
align-items: baseline;
75+
gap: 0.6rem;
76+
}
77+
ol.backlog > li h3::before {
78+
content: counter(item);
79+
color: var(--accent);
80+
font-variant-numeric: tabular-nums;
81+
font-size: 0.9rem;
82+
font-weight: 600;
83+
min-width: 1.4rem;
84+
}
85+
ol.backlog > li p {
86+
margin: 0;
87+
color: var(--text);
88+
}
89+
</style>
90+
</head>
91+
<body>
92+
<main>
93+
<header class="doc">
94+
<h1>Write support</h1>
95+
<p class="generated">Generated 2026-07-11</p>
96+
<p class="overview">
97+
SwiftFITS is currently read-only: it parses existing FITS files into their
98+
header/data structure and round-trips them byte-for-byte, but cannot produce
99+
or alter files. This backlog collects the work needed to add write and
100+
serialization support to the library.
101+
</p>
102+
</header>
103+
104+
<h2 class="section-title">Backlog</h2>
105+
106+
<ol class="backlog">
107+
<li>
108+
<h3>Allow creating, modifying and writing FITS files</h3>
109+
<p>
110+
Extend SwiftFITS beyond read-only parsing so consumers can create new FITS
111+
files from scratch, modify the headers and data of parsed files, and
112+
serialize them back out to disk in a standards-compliant form.
113+
</p>
114+
</li>
115+
</ol>
116+
</main>
117+
</body>
118+
</html>

SwiftFITS.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
/* Begin PBXBuildFile section */
1010
0516D2972DDD330D00A10FDA /* FITSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0516D2962DDD330A00A10FDA /* FITSError.swift */; };
1111
0516D2992DDDA03B00A10FDA /* FITSParsingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0516D2982DDDA02F00A10FDA /* FITSParsingOptions.swift */; };
12+
05F1720A0000000000B1B100 /* FITSSerializationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1720A0000000000A1A100 /* FITSSerializationOptions.swift */; };
1213
053BEAF32DDE44A500EF0C79 /* Stacked_462_mosaic_M 42_10.0s_LP_20250124-012002.fit in Resources */ = {isa = PBXBuildFile; fileRef = 053BEAF22DDE44A500EF0C79 /* Stacked_462_mosaic_M 42_10.0s_LP_20250124-012002.fit */; };
1314
05C5278C2DD8E36300843626 /* SwiftFITS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05C527822DD8E36300843626 /* SwiftFITS.framework */; };
1415
05C5279B2DD8EDAF00843626 /* FITSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05C5279A2DD8EDA800843626 /* FITSError.swift */; };
@@ -43,6 +44,7 @@
4344
05E5A0020000000000A1A100 /* m35_40min_blue.fits in Resources */ = {isa = PBXBuildFile; fileRef = 05E5A0010000000000A1A100 /* m35_40min_blue.fits */; };
4445
0AFCDA7A0000000000B1B100 /* FITSValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFCDA7A0000000000F1F100 /* FITSValue.swift */; };
4546
0AFCDA7A0000000000C1C100 /* FITSValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFCDA7A0000000000A1A100 /* FITSValue.swift */; };
47+
05F1720B0000000000B1B100 /* FITSSerializationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1720B0000000000A1A100 /* FITSSerializationOptions.swift */; };
4648
/* End PBXBuildFile section */
4749

4850
/* Begin PBXContainerItemProxy section */
@@ -58,6 +60,8 @@
5860
/* Begin PBXFileReference section */
5961
0516D2962DDD330A00A10FDA /* FITSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FITSError.swift; sourceTree = "<group>"; };
6062
0516D2982DDDA02F00A10FDA /* FITSParsingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FITSParsingOptions.swift; sourceTree = "<group>"; };
63+
05F1720B0000000000A1A100 /* FITSSerializationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FITSSerializationOptions.swift; sourceTree = "<group>"; };
64+
05F1720A0000000000A1A100 /* FITSSerializationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FITSSerializationOptions.swift; sourceTree = "<group>"; };
6165
053BEAF22DDE44A500EF0C79 /* Stacked_462_mosaic_M 42_10.0s_LP_20250124-012002.fit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Stacked_462_mosaic_M 42_10.0s_LP_20250124-012002.fit"; sourceTree = "<group>"; };
6266
05C527822DD8E36300843626 /* SwiftFITS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftFITS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6367
05C5278B2DD8E36300843626 /* SwiftFITSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftFITSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -225,6 +229,7 @@
225229
05C5279D2DD8EF6600843626 /* TestUtilities.swift */,
226230
05C5279F2DD8EF6600843626 /* TestError.swift */,
227231
0AFCDA7A0000000000A1A100 /* FITSValue.swift */,
232+
05F1720B0000000000A1A100 /* FITSSerializationOptions.swift */,
228233
);
229234
path = SwiftFITSTests;
230235
sourceTree = "<group>";
@@ -238,6 +243,7 @@
238243
05C5279A2DD8EDA800843626 /* FITSError.swift */,
239244
05D5890E2DD8E470000BA644 /* FITSFile.swift */,
240245
0516D2982DDDA02F00A10FDA /* FITSParsingOptions.swift */,
246+
05F1720A0000000000A1A100 /* FITSSerializationOptions.swift */,
241247
0AFCDA7A0000000000F1F100 /* FITSValue.swift */,
242248
05C527D52DD9D5BC00843626 /* FITSProperty.swift */,
243249
05C527CA2DD9225C00843626 /* FITSSection.swift */,
@@ -547,6 +553,7 @@
547553
05C527C72DD91FEA00843626 /* String.swift in Sources */,
548554
05C5279B2DD8EDAF00843626 /* FITSError.swift in Sources */,
549555
0516D2992DDDA03B00A10FDA /* FITSParsingOptions.swift in Sources */,
556+
05F1720A0000000000B1B100 /* FITSSerializationOptions.swift in Sources */,
550557
0AFCDA7A0000000000B1B100 /* FITSValue.swift in Sources */,
551558
05C527A02DD9000900843626 /* Data.swift in Sources */,
552559
05D589102DD8E470000BA644 /* FITSFile.swift in Sources */,
@@ -571,6 +578,7 @@
571578
05C527A02DD8EF7200843626 /* TestError.swift in Sources */,
572579
0AFCDA7A0000000000C1C100 /* FITSValue.swift in Sources */,
573580
0516D2972DDD330D00A10FDA /* FITSError.swift in Sources */,
581+
05F1720B0000000000B1B100 /* FITSSerializationOptions.swift in Sources */,
574582
);
575583
runOnlyForDeploymentPostprocessing = 0;
576584
};

SwiftFITS/FITSBlock.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public class FITSBlock: CustomStringConvertible
8282
/// block whose `END` is not its last non-blank record still ends the section.
8383
public private( set ) lazy var hasEndMarker: Bool =
8484
{
85-
guard self.containsOnlyASCII, let lines = try? self.data.chunked( by: 80 )
85+
guard self.containsOnlyASCII, let lines = try? self.data.chunked( by: FITSFile.cardSize )
8686
else
8787
{
8888
return false

SwiftFITS/FITSError.swift

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ public enum FITSError: LocalizedError, CustomStringConvertible, Sendable
5757
/// problem.
5858
case dataError( reason: String )
5959

60+
/// A value could not be rendered to its FITS serialized form; `reason`
61+
/// describes the specific problem.
62+
case invalidValueForSerialization( reason: String )
63+
64+
/// A file or section could not be serialized to FITS data; `reason`
65+
/// describes the specific problem.
66+
case cannotSerialize( reason: String )
67+
68+
/// The serialized data could not be written to the given URL.
69+
case cannotWriteFile( url: URL )
70+
6071
/// A human-readable description prefixed with `FITS Error:`.
6172
public var description: String
6273
{
@@ -68,14 +79,17 @@ public enum FITSError: LocalizedError, CustomStringConvertible, Sendable
6879
{
6980
switch self
7081
{
71-
case .invalidFileURL( let url ): return "Invalid file URL: \( url )"
72-
case .cannotReadFile( let url ): return "Cannot read file: \( url )"
73-
case .invalidBlockSize( let size ): return "Invalid block size: \( size )"
74-
case .invalidBlockData( let reason ): return "Invalid block data: \( reason )"
75-
case .invalidSectionData( let reason ): return "Invalid section data: \( reason )"
76-
case .invalidFileData( let reason ): return "Invalid file data: \( reason )"
77-
case .invalidPropertyData( let reason ): return "Invalid property data: \( reason )"
78-
case .dataError( let reason ): return "Data error: \( reason )"
82+
case .invalidFileURL( let url ): return "Invalid file URL: \( url )"
83+
case .cannotReadFile( let url ): return "Cannot read file: \( url )"
84+
case .invalidBlockSize( let size ): return "Invalid block size: \( size )"
85+
case .invalidBlockData( let reason ): return "Invalid block data: \( reason )"
86+
case .invalidSectionData( let reason ): return "Invalid section data: \( reason )"
87+
case .invalidFileData( let reason ): return "Invalid file data: \( reason )"
88+
case .invalidPropertyData( let reason ): return "Invalid property data: \( reason )"
89+
case .dataError( let reason ): return "Data error: \( reason )"
90+
case .invalidValueForSerialization( let reason ): return "Invalid value for serialization: \( reason )"
91+
case .cannotSerialize( let reason ): return "Cannot serialize: \( reason )"
92+
case .cannotWriteFile( let url ): return "Cannot write file: \( url )"
7993
}
8094
}
8195
}

SwiftFITS/FITSFile.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ public class FITSFile: CustomStringConvertible
4040
/// The size, in bytes, of a single FITS block. Fixed by the standard at 2880.
4141
public static let blockSize = 2880
4242

43+
/// The size, in bytes, of a single FITS header record (card). Fixed by the
44+
/// standard at 80.
45+
public static let cardSize = 80
46+
47+
/// The length, in bytes, of the keyword-name field at the start of a header
48+
/// record. Fixed by the standard at 8.
49+
public static let keywordLength = 8
50+
4351
/// An upper bound, in bytes, on a single data segment.
4452
///
4553
/// A geometry implying a larger segment is rejected as corrupt rather than

SwiftFITS/FITSProperty.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public class FITSProperty: CustomStringConvertible
7575
/// not 80 characters, is not ASCII, or cannot be parsed.
7676
public init( string: String, options: FITSParsingOptions ) throws
7777
{
78-
guard string.count == 80
78+
guard string.count == FITSFile.cardSize
7979
else
8080
{
8181
throw FITSError.invalidPropertyData( reason: "Invalid property data length (\( string.count ))" )
@@ -87,23 +87,23 @@ public class FITSProperty: CustomStringConvertible
8787
throw FITSError.invalidPropertyData( reason: "Record must be ASCII" )
8888
}
8989

90-
let name = try FITSProperty.parseName( string: String( string.prefix( 8 ) ), options: options )
90+
let name = try FITSProperty.parseName( string: String( string.prefix( FITSFile.keywordLength ) ), options: options )
9191

9292
if name == "HISTORY" || name == "COMMENT"
9393
{
9494
self.name = name
9595
self.value = .undefined
96-
self.comment = try FITSProperty.parseCommentOnly( string: String( string.dropFirst( 8 ) ), options: options )
96+
self.comment = try FITSProperty.parseCommentOnly( string: String( string.dropFirst( FITSFile.keywordLength ) ), options: options )
9797
}
9898
else if name.isEmpty
9999
{
100100
self.name = name
101101
self.value = .undefined
102-
self.comment = try FITSProperty.parseCommentOnly( string: String( string.dropFirst( 8 ) ), options: options )
102+
self.comment = try FITSProperty.parseCommentOnly( string: String( string.dropFirst( FITSFile.keywordLength ) ), options: options )
103103
}
104104
else
105105
{
106-
let ( value, comment ) = try FITSProperty.parseValueAndComment( name: name, string: String( string.dropFirst( 8 ) ), options: options )
106+
let ( value, comment ) = try FITSProperty.parseValueAndComment( name: name, string: String( string.dropFirst( FITSFile.keywordLength ) ), options: options )
107107
self.name = name
108108
self.value = value
109109
self.comment = comment
@@ -549,7 +549,7 @@ public class FITSProperty: CustomStringConvertible
549549
/// A single-line, human-readable summary of the property.
550550
public var description: String
551551
{
552-
let name = self.name.padding( toLength: 8, withPad: " ", startingAt: 0 )
552+
let name = self.name.padding( toLength: FITSFile.keywordLength, withPad: " ", startingAt: 0 )
553553
let comment = self.comment?.replacingOccurrences( of: "\n", with: "\\n" ) ?? "<nil>"
554554
let value = switch self.value
555555
{

SwiftFITS/FITSSection.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ public class FITSSection: CustomStringConvertible
303303
/// no predecessor to merge into.
304304
private class func readAndMergeProperties( data: Data, options: FITSParsingOptions ) throws -> [ FITSProperty ]
305305
{
306-
try data.chunked( by: 80 ).map
306+
try data.chunked( by: FITSFile.cardSize ).map
307307
{
308308
try FITSProperty( data: $0, options: options )
309309
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*******************************************************************************
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2026, Jean-David Gadina - www.xs-labs.com
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the Software), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
******************************************************************************/
24+
25+
import Foundation
26+
27+
/// Options controlling how strictly FITS data is validated and rendered when
28+
/// serialized back to bytes.
29+
///
30+
/// This is the write-side counterpart to ``FITSParsingOptions``. It offers the
31+
/// same two presets — ``strict`` and ``lenient`` — so a consumer can choose
32+
/// between spec-faithful output that rejects anything the FITS standard forbids
33+
/// and real-world-friendly output that tolerates the same noncompliant
34+
/// constructs the parser accepts.
35+
///
36+
/// The concrete option flags are introduced as the writer is built out across
37+
/// the serialization milestones (value and card rendering, section assembly,
38+
/// on-write validation); until then both presets are the empty set, meaning
39+
/// "no special behavior".
40+
public struct FITSSerializationOptions: OptionSet, Sendable
41+
{
42+
/// The raw bitmask backing the option set.
43+
public let rawValue: Int
44+
45+
/// Creates an option set from its raw bitmask value.
46+
///
47+
/// - Parameter rawValue: The bitmask of enabled options.
48+
public init( rawValue: Int )
49+
{
50+
self.rawValue = rawValue
51+
}
52+
53+
/// Spec-faithful serialization: emits standards-compliant bytes and rejects
54+
/// any content the FITS standard forbids.
55+
///
56+
/// Concrete flags are added to this preset as the writer's validation and
57+
/// rendering rules are implemented.
58+
public static let strict: FITSSerializationOptions = []
59+
60+
/// Real-world-friendly serialization: like ``strict`` but tolerates the
61+
/// noncompliant constructs found in many existing FITS files.
62+
///
63+
/// Concrete flags are added to this preset as the writer's leniency rules
64+
/// are implemented.
65+
public static let lenient: FITSSerializationOptions = []
66+
}

SwiftFITSTests/FITSError.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,17 @@ struct Test_FITSError
3232
func description() async throws
3333
{
3434
[
35-
( error: FITSError.invalidFileURL( url: URL( fileURLWithPath: "/foo/bar.fits" ) ), contains: "/foo/bar.fits" ),
36-
( error: FITSError.cannotReadFile( url: URL( fileURLWithPath: "/foo/bar.fits" ) ), contains: "/foo/bar.fits" ),
37-
( error: FITSError.invalidBlockSize( size: 42 ), contains: "42" ),
38-
( error: FITSError.invalidBlockData( reason: "This is a test" ), contains: "This is a test" ),
39-
( error: FITSError.invalidSectionData( reason: "This is a test" ), contains: "This is a test" ),
40-
( error: FITSError.invalidFileData( reason: "This is a test" ), contains: "This is a test" ),
41-
( error: FITSError.invalidPropertyData( reason: "This is a test" ), contains: "This is a test" ),
42-
( error: FITSError.dataError( reason: "This is a test" ), contains: "This is a test" ),
35+
( error: FITSError.invalidFileURL( url: URL( fileURLWithPath: "/foo/bar.fits" ) ), contains: "/foo/bar.fits" ),
36+
( error: FITSError.cannotReadFile( url: URL( fileURLWithPath: "/foo/bar.fits" ) ), contains: "/foo/bar.fits" ),
37+
( error: FITSError.invalidBlockSize( size: 42 ), contains: "42" ),
38+
( error: FITSError.invalidBlockData( reason: "This is a test" ), contains: "This is a test" ),
39+
( error: FITSError.invalidSectionData( reason: "This is a test" ), contains: "This is a test" ),
40+
( error: FITSError.invalidFileData( reason: "This is a test" ), contains: "This is a test" ),
41+
( error: FITSError.invalidPropertyData( reason: "This is a test" ), contains: "This is a test" ),
42+
( error: FITSError.dataError( reason: "This is a test" ), contains: "This is a test" ),
43+
( error: FITSError.invalidValueForSerialization( reason: "This is a test" ), contains: "This is a test" ),
44+
( error: FITSError.cannotSerialize( reason: "This is a test" ), contains: "This is a test" ),
45+
( error: FITSError.cannotWriteFile( url: URL( fileURLWithPath: "/foo/bar.fits" ) ), contains: "/foo/bar.fits" ),
4346
]
4447
.forEach
4548
{

0 commit comments

Comments
 (0)