diff --git a/CLAUDE.md b/CLAUDE.md index 77c565f..87b39ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ It is the fastest way to answer a layout question without a GUI: swift run ckprobe tune.abc # full report swift run ckprobe tune.abc --scale 0.6 # override %%scale (default 0.75) swift run ckprobe tune.abc --sweep 1.0,0.75,0.6 # systems/pages per %%scale -swift run ckprobe tune.abc --natural # unstretched system widths +swift run ckprobe tune.abc --natural # unstretched system widths (%%stretchstaff 0) swift run ckprobe tune.abc --out /tmp/out --json swift run ckprobe --fonts [--system-fonts] # faces text could be set in swift run ckprobe tune.abc --out /tmp/out --font-face # + @font-face @@ -112,7 +112,8 @@ Eight `%%ceolkit:*` directives are first-class model members: - `%%ceolkit:pipeformat true|false` - `%%ceolkit:pagenumber N` - `%%ceolkit:stemalignment N` -- `%%ceolkit:justifylast true|false` +- `%%ceolkit:justifylast true|false` (**deprecated**: parsed as `%%stretchlast 1|0` with a + `deprecatedDirective` warning; the last line is abcm2ps's `%%stretchlast`/`%%stretchstaff`) - `%%ceolkit:scale F` (**deprecated**: parsed as `%%pagescale F` with a `deprecatedDirective` warning; the page scale is `%%scale`/`%%pagescale`, as in abcm2ps — see `EXTENSIONS.md`) - `%%ceolkit:gracenotespacing F` (F >= 1, in grace notehead widths; tune-wide, never per-voice) diff --git a/EXTENSIONS.md b/EXTENSIONS.md index 505a47f..bd02e47 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -161,21 +161,18 @@ K:A --- -## `%%ceolkit:justifylast` +## `%%ceolkit:justifylast` (deprecated) **Syntax:** `%%ceolkit:justifylast ` -**Type:** boolean (`true` or `false`; case-insensitive) - -**Default:** `false` - -**Scope:** global (file preamble or tune header) - -### Description +**Status:** deprecated since issue #198; use `%%stretchlast` (see +[The last line](#the-last-line-stretchlast-and-stretchstaff) below). It will be removed +in a later release. -The default way to render a tune is not to justify the last line, leaving it ragged. -This setting, when `true`, forces the last line to be justified so that it spans the -whole width. +`%%ceolkit:justifylast true` now means `%%stretchlast 1`, and `false` means +`%%stretchlast 0`. Either produces a `deprecatedDirective` warning naming the +replacement. Note that `false` no longer draws the last line at its natural width: +like abcm2ps, a short last line takes the spacing of the line above it. --- @@ -565,6 +562,45 @@ be removed in a later release. the title block and the `W:` words along with the music, which it did not do before. +## The last line: `%%stretchlast` and `%%stretchstaff` + +These are abcm2ps's directives (ABC v2.2 §11.4.3 lists both), implemented the way +abcm2ps 8.14.0 behaves (issue #198). Every system but the last is stretched to the +full line. The last system of a tune is handled like this: + +- **`%%stretchlast F`**, with `F` from 0 to 1. The default is **0.25**. The last + system is stretched to the full line when its unstretched staff (header and music) + reaches `1 − F` of the line. At the default, that means three quarters of the line. + `1` stretches every last line, and `0` stretches none. The spec's logical form is + accepted too, with `true` meaning 1 and `false` meaning 0. abcm2ps accepts only the + number. +- A last system that is **not** stretched is spaced like the line before it, so its + notes do not suddenly close up at the end of the tune. It is never squeezed below + its natural width and never runs past the line. A tune of one line has no line + before it, so its last line is drawn at natural width. +- **`%%stretchstaff `** (default `true`). `false` stretches no system at + all, the last one included. A system that overruns the line is still compressed to + fit. + +A value that is not a number from 0 to 1, or a logical, produces an +`invalidStretchLast` warning, and the directive is ignored. + +Both are scoped like `%%scale`: written in the file header, the value governs every +tune; written in a tune, it governs that tune only. `SVGRenderConfig.stretchLast` and +`SVGRenderConfig.stretchStaff` set the values the host uses where the document says +nothing. + +```abc +X:1 +T:Last line always stretched +%%stretchlast 1 +M:6/8 +L:1/8 +K:G +GAB cde | dcB A3 | B2c d2d | cdc B3 | +B2c d2d |] +``` + ## The page scale: `%%scale` and `%%pagescale` These are abcm2ps's directives (ABC v2.2 §11.4.3 lists `%%scale`), implemented diff --git a/README.md b/README.md index 3fecd24..1f4f11f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ pages: 2 | `--scale ` | Override `%%scale` (abcm2ps's `-s`; default 0.75) before rendering. | | `--grace-spacing ` | Override `%%ceolkit:gracenotespacing` (≥ 1) before rendering. | | `--sweep ` | Render at each `%%scale`; print a systems/pages table. | -| `--natural` | Force `%%ceolkit:justifylast false`, so widths are unstretched. | +| `--natural` | Force `%%stretchstaff 0`, so every system's width is unstretched. | | `--out ` | Write `page0.svg`, `page1.svg`, … into ``. | | `--json` | Emit JSON instead of the text report. | diff --git a/Sources/CeolKitModel/CeolKitDirective.swift b/Sources/CeolKitModel/CeolKitDirective.swift index de015a4..26e7437 100644 --- a/Sources/CeolKitModel/CeolKitDirective.swift +++ b/Sources/CeolKitModel/CeolKitDirective.swift @@ -18,7 +18,16 @@ public enum CeolKitDirective: Hashable, Sendable { case graceNoteSpacing(Double) // %%ceolkit:gracenotespacing F (F >= 1) case landscape(Bool) // %%landscape 0|1 (ABC v2.2 §9.1) case flatBeams(Bool) // %%flatbeams true|false (abcm2ps; implicit in pipeFormat) - case justifyLast(Bool) // %%ceolkit:justifylast true|false + /// How short the last system of a tune may be and still be stretched to the full line, + /// as abcm2ps's `%%stretchlast` value (issue #198): the system is stretched when its + /// natural width reaches `1 − F` of the line. `0.25` is abcm2ps's — and CeolKit's — + /// default; `0` never stretches it, `1` always does. The spec's logical form and the + /// deprecated `%%ceolkit:justifylast` are `1` for true and `0` for false. + case stretchLast(Double) // %%stretchlast F (0…1) | true|false | %%ceolkit:justifylast + /// Whether systems are stretched to the line at all, from `%%stretchstaff` (ABC v2.2 + /// §11.4.3 lists it; abcm2ps defaults it to true). `false` draws every system at its + /// natural width, the last one included. + case stretchStaff(Bool) // %%stretchstaff true|false case label(String) // %%ceolkit:label "" (value of a `${label}` footer mark) case writeFields(String, Bool) // %%writefields [true|false] (ABC v2.2 §11.4.6) case dateFormat(String) // %%dateformat (abcm2ps/abc2svg) diff --git a/Sources/CeolKitModel/Diagnostic.swift b/Sources/CeolKitModel/Diagnostic.swift index 0271ffc..70c373f 100644 --- a/Sources/CeolKitModel/Diagnostic.swift +++ b/Sources/CeolKitModel/Diagnostic.swift @@ -76,6 +76,9 @@ public enum DiagnosticCode: String, Codable, Sendable { /// would change scale from the next music line; that is not implemented.) case scaleAppliesToWholeTune case invalidGraceNoteSpacing + /// `%%stretchlast` whose value is neither a number from 0 to 1 nor a logical, or a + /// `%%ceolkit:justifylast` that is not `true` or `false`. + case invalidStretchLast /// A font directive (§11.4.2) whose payload is not ` []`: no name, a /// size that is not a positive number, or words after the size. case invalidFontDirective @@ -83,7 +86,8 @@ public enum DiagnosticCode: String, Codable, Sendable { case unknownDirective case redundantDirective /// A directive CeolKit still honours but is going to remove, with its replacement named - /// in the message — `%%ceolkit:scale`, which is `%%pagescale` (issue #203). + /// in the message — `%%ceolkit:scale`, which is `%%pagescale` (issue #203), and + /// `%%ceolkit:justifylast`, which is `%%stretchlast` (issue #198). case deprecatedDirective /// A `%%footer` template contains a `$`-token CeolKit does not substitute — an /// abcm2ps placeholder that is not implemented here, or a typo such as `$p`. The diff --git a/Sources/CeolKitParser/Semantic/SemanticPass.swift b/Sources/CeolKitParser/Semantic/SemanticPass.swift index 8725b3d..70f9a99 100644 --- a/Sources/CeolKitParser/Semantic/SemanticPass.swift +++ b/Sources/CeolKitParser/Semantic/SemanticPass.swift @@ -249,7 +249,8 @@ struct SemanticPass { || name == "dateformat" || name == "footer" || name == "straightflags" || name == "graceslurs" || name == "score" || name == "staves" || name == "newpage" - || name == "scale" || name == "pagescale" + || name == "scale" || name == "pagescale" || name == "stretchlast" + || name == "stretchstaff" || TextFontRole(rawValue: name) != nil } @@ -1071,7 +1072,8 @@ struct SemanticPass { beforeStave: ctx.currentStaveIndex, restartingAt: parseNewPage(payload, source: source, diagnostics: &diagnostics), source: source)) - case "landscape", "flatbeams", "ceolkit:justifylast", "ceolkit:scale", + case "landscape", "flatbeams", "ceolkit:justifylast", "stretchlast", "stretchstaff", + "ceolkit:scale", "scale", "pagescale", "ceolkit:gracenotespacing", "ceolkit:fontlist", "writefields", "dateformat", "footer", "straightflags", "graceslurs", _ where TextFontRole(rawValue: name) != nil: @@ -1549,11 +1551,39 @@ struct SemanticPass { diagnostics.append(Diagnostic(severity: .warning, code: .unknownDirective, message: "%%flatbeams expects '0'/'false' or '1'/'true'", source: source)) return nil - case "ceolkit:justifylast": - if let value = parseLogical(trimmed) { return .justifyLast(value) } + case "stretchlast": + // abcm2ps takes a fraction of the line and rejects anything outside 0…1; the + // ABC v2.2 spec's list of directives gives it a logical, which is the two ends of + // that range. A number is tried first, so `0` and `1` mean the same either way. + if let f = Double(trimmed), f.isFinite { + guard (0...1).contains(f) else { + diagnostics.append(Diagnostic(severity: .warning, code: .invalidStretchLast, + message: "%%stretchlast must be between 0 and 1 (got \(trimmed))", + source: source)) + return nil + } + return .stretchLast(f) + } + if let value = parseLogical(trimmed) { return .stretchLast(value ? 1 : 0) } + diagnostics.append(Diagnostic(severity: .warning, code: .invalidStretchLast, + message: "%%stretchlast expects a number from 0 to 1, or 'true'/'false' " + + "(got '\(trimmed)')", source: source)) + return nil + case "stretchstaff": + if let value = parseLogical(trimmed) { return .stretchStaff(value) } diagnostics.append(Diagnostic(severity: .warning, code: .unknownDirective, - message: "%%ceolkit:justifylast expects 'true' or 'false'", source: source)) + message: "%%stretchstaff expects '0'/'false' or '1'/'true'", source: source)) return nil + case "ceolkit:justifylast": + guard let value = parseLogical(trimmed) else { + diagnostics.append(Diagnostic(severity: .warning, code: .invalidStretchLast, + message: "%%ceolkit:justifylast expects 'true' or 'false'", source: source)) + return nil + } + diagnostics.append(Diagnostic(severity: .warning, code: .deprecatedDirective, + message: "%%ceolkit:justifylast is deprecated; use %%stretchlast \(value ? 1 : 0), " + + "which it now means", source: source)) + return .stretchLast(value ? 1 : 0) case "ceolkit:fontlist": if trimmed.isEmpty { return .fontList(.resolved) } if let mode = FontListMode(rawValue: trimmed.lowercased()) { return .fontList(mode) } diff --git a/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift b/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift index 133dc12..3d47aac 100644 --- a/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift +++ b/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift @@ -384,7 +384,7 @@ public struct SVGRenderer: CeolKitRenderer { } let tuneGroups = groups.isEmpty ? [] : justifier.justifyGroups( groups, usableWidth: usableWidth(on: pageSizes.opening), - justifyLastSystem: layout.justifyLastSystem, + stretchLast: layout.stretchLast, stretchStaff: layout.stretchStaff, systemHeaderWidths: headerWidths, systemUsableWidths: usableWidthOfGroup) // Build the title block for this tune per §6.1.3. diff --git a/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift b/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift index 65da5e3..e533e73 100644 --- a/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift +++ b/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift @@ -38,7 +38,15 @@ public struct SVGRenderConfig: Sendable { public var spanStaffGap: Double /// Vertical gap added after the last system of a tune, before the next tune's title block. public var tuneGap: Double - public var justifyLastSystem: Bool + /// abcm2ps's `%%stretchlast`, from 0 to 1: the last system of a tune is stretched to the + /// full line when its natural width reaches `1 − stretchLast` of it (issue #198). The + /// default, `0.25`, is abcm2ps's. `0` leaves every last system at its natural width; `1` + /// stretches every one. A `%%stretchlast` in the document overrides it. + public var stretchLast: Double + /// abcm2ps's `%%stretchstaff`: whether systems are stretched to the line at all. `false` + /// draws every system at its natural width, the last one included; a system that + /// overruns is still compressed to fit. A `%%stretchstaff` in the document overrides it. + public var stretchStaff: Bool public var straightFlags: Bool public var graceSlurs: Bool /// Step between adjacent grace noteheads within one grace group, as a multiple of the @@ -86,7 +94,8 @@ public struct SVGRenderConfig: Sendable { staffGap: Double? = nil, spanStaffGap: Double? = nil, tuneGap: Double? = nil, - justifyLastSystem: Bool = false, + stretchLast: Double = 0.25, + stretchStaff: Bool = true, straightFlags: Bool = false, graceSlurs: Bool = true, graceNoteSpacing: Double = 1.05, @@ -105,7 +114,8 @@ public struct SVGRenderConfig: Sendable { self.staffGap = staffGap ?? staffSize * 3 self.spanStaffGap = spanStaffGap ?? staffSize * 2 self.tuneGap = tuneGap ?? staffSize * 16 - self.justifyLastSystem = justifyLastSystem + self.stretchLast = min(max(stretchLast, 0), 1) + self.stretchStaff = stretchStaff self.straightFlags = straightFlags self.graceSlurs = graceSlurs self.graceNoteSpacing = graceNoteSpacing diff --git a/Sources/CeolKitSVGRenderer/Layout/Justifier.swift b/Sources/CeolKitSVGRenderer/Layout/Justifier.swift index b49c993..7fd03a5 100644 --- a/Sources/CeolKitSVGRenderer/Layout/Justifier.swift +++ b/Sources/CeolKitSVGRenderer/Layout/Justifier.swift @@ -1,5 +1,6 @@ /// Pass 3: distributes horizontal slack across measures so each non-last system -/// fills the full usable line width. +/// fills the full usable line width — and the last one too, once it is full enough +/// (`%%stretchlast`, issue #198). /// /// Two things stop that from being unconditional. A system whose music overruns the line — /// which the `LineBreaker` now allows within its overflow tolerance — is compressed to fit @@ -32,19 +33,24 @@ public struct Justifier: Sendable { /// - Parameters: /// - systems: Pass 2 output. /// - usableWidth: Full available horizontal space (page width minus margins). - /// - justifyLastSystem: When `true`, the last system is also stretched to fill the line. + /// - stretchLast: abcm2ps's `%%stretchlast`: the last system is stretched too when its + /// natural width reaches `1 − stretchLast` of the line; see + /// ``stretchesLast(naturalWidth:lineWidth:headerWidth:stretchLast:)``. + /// - stretchStaff: abcm2ps's `%%stretchstaff`. `false` stretches no system at all. /// - systemHeaderWidths: Per-system width consumed by clef/key/time-sig headers. /// The target width for system `i` is `usableWidth - systemHeaderWidths[i]`. /// Defaults to zero for any system not covered by the array. public func justify( _ systems: [System], usableWidth: Double, - justifyLastSystem: Bool, + stretchLast: Double, + stretchStaff: Bool = true, systemHeaderWidths: [Double] = [] ) -> [JustifiedSystem] { justifyGroups(systems.map { SystemGroup(staves: [$0]) }, usableWidth: usableWidth, - justifyLastSystem: justifyLastSystem, + stretchLast: stretchLast, + stretchStaff: stretchStaff, systemHeaderWidths: systemHeaderWidths) .map { $0.staves[0] } } @@ -59,7 +65,10 @@ public struct Justifier: Sendable { /// - Parameters: /// - groups: Pass 2 output. /// - usableWidth: Full available horizontal space (page width minus margins). - /// - justifyLastSystem: When `true`, the last system is also stretched to fill the line. + /// - stretchLast: abcm2ps's `%%stretchlast`: the last system is stretched too when its + /// natural width reaches `1 − stretchLast` of the line; see + /// ``stretchesLast(naturalWidth:lineWidth:headerWidth:stretchLast:)``. + /// - stretchStaff: abcm2ps's `%%stretchstaff`. `false` stretches no system at all. /// - systemHeaderWidths: Per-system width consumed by clef/key/time-sig headers — /// already the `max` across the group's voices, since its staves start at a common x. /// - systemUsableWidths: Per-system line width, where the systems of one tune do not all @@ -73,31 +82,85 @@ public struct Justifier: Sendable { public func justifyGroups( _ groups: [SystemGroup], usableWidth: Double, - justifyLastSystem: Bool, + stretchLast: Double, + stretchStaff: Bool = true, systemHeaderWidths: [Double] = [], systemUsableWidths: [Double] = [] ) -> [JustifiedSystemGroup] { - groups.enumerated().map { i, group in + // How far the line before was stretched, as a multiple of its natural width — what a + // last system left short is spaced at. `1` before the first: natural spacing. + var previousSpread = 1.0 + return groups.enumerated().map { i, group in let headerWidth = i < systemHeaderWidths.count ? systemHeaderWidths[i] : 0 let lineWidth = i < systemUsableWidths.count ? systemUsableWidths[i] : usableWidth let targetWidth = lineWidth - headerWidth - let shouldStretch = !group.isLastSystem || justifyLastSystem - return justify(group, targetWidth: targetWidth, stretch: shouldStretch, - capStretch: group.staveWasSplit) + let naturalTotal = Self.naturalWidth(of: group) + let finalTotal: Double + if !stretchStaff { + // `%%stretchstaff 0`: every system natural, and only an overrun squeezed. + finalTotal = min(naturalTotal, targetWidth) + } else if !group.isLastSystem + || Self.stretchesLast(naturalWidth: naturalTotal, lineWidth: lineWidth, + headerWidth: headerWidth, stretchLast: stretchLast) { + finalTotal = resolvedWidth(naturalTotal: naturalTotal, targetWidth: targetWidth, + capStretch: group.staveWasSplit) + } else { + finalTotal = shortLastWidth(naturalTotal: naturalTotal, targetWidth: targetWidth, + previousSpread: previousSpread, + capStretch: group.staveWasSplit) + } + if naturalTotal > 0 { previousSpread = finalTotal / naturalTotal } + return justify(group, finalTotal: finalTotal) } } + /// Whether the last system of a tune is stretched to the line, as abcm2ps decides it: + /// when the staff it would draw unstretched — header and music — reaches `1 − F` of the + /// line, for `%%stretchlast F`. + /// + /// abcm2ps 8.14.0, on a 680 pt line at the default `0.25`, leaves a last line filling + /// 0.69 of it short and stretches one filling 0.75; at `0.6` it stretches one filling + /// 0.52. `0` stretches no last line to the full width, `1` stretches every one. A last + /// line that is not stretched is still not drawn at its natural width; see + /// ``shortLastWidth(naturalTotal:targetWidth:previousSpread:capStretch:)``. + static func stretchesLast(naturalWidth: Double, lineWidth: Double, headerWidth: Double, + stretchLast: Double) -> Bool { + guard stretchLast > 0 else { return false } + return headerWidth + naturalWidth >= (1 - stretchLast) * lineWidth + } + + /// A system's natural width: each column as wide as its widest staff needs. + static func naturalWidth(of group: SystemGroup) -> Double { + (0.. Double { + var width = naturalTotal * max(1, previousSpread) + if capStretch { width = min(width, naturalTotal * maxStretch) } + return min(width, targetWidth) + } + // MARK: - Private - private func justify(_ group: SystemGroup, targetWidth: Double, stretch: Bool, - capStretch: Bool) -> JustifiedSystemGroup { + private func justify(_ group: SystemGroup, finalTotal: Double) -> JustifiedSystemGroup { // A column is as wide as its widest staff needs; every staff is then drawn to that. let columnWidths = (0.. Double { + capStretch: Bool) -> Double { if naturalTotal > targetWidth { return targetWidth } - guard stretch else { return naturalTotal } guard capStretch else { return targetWidth } return min(targetWidth, naturalTotal * maxStretch) } diff --git a/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift b/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift index 0027805..f8d19a8 100644 --- a/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift +++ b/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift @@ -14,9 +14,11 @@ struct LayoutDirectives { /// choice to the note's staff position, which is the ordinary engraving rule. A voice's /// own `V:` `stem=` outranks it; the emitter resolves the two per staff (issue #74). var stemDirection: StemDirection = .auto - /// Whether the last system of a tune is stretched to the full measure, from - /// `%%ceolkit:justifylast`. - var justifyLastSystem: Bool + /// How full the last system of a tune must be to be stretched to the line, from + /// `%%stretchlast` (or the deprecated `%%ceolkit:justifylast`). + var stretchLast: Double + /// Whether systems are stretched to the line at all, from `%%stretchstaff`. + var stretchStaff: Bool /// The page scale, as abcm2ps's `%%scale` value: ``SVGRenderConfig/scale`` unless /// `%%scale`, `%%pagescale` or `%%ceolkit:scale` says otherwise (issue #203). var scale: Double @@ -49,7 +51,8 @@ struct LayoutDirectives { /// The document baseline before any directive has been read: what the config asks for. init(config: SVGRenderConfig) { fonts = config.textFonts - justifyLastSystem = config.justifyLastSystem + stretchLast = config.stretchLast + stretchStaff = config.stretchStaff scale = config.scale graceNoteSpacing = config.graceNoteSpacing straightFlags = config.straightFlags @@ -65,7 +68,8 @@ struct LayoutDirectives { // saying `pipeformat false` under a preamble saying `true` is asking for the // ordinary pitch rule, and nothing else in the tune can ask for it. case .pipeFormat(let on): stemDirection = on ? .down : .auto - case .justifyLast(let on): justifyLastSystem = on + case .stretchLast(let fraction): stretchLast = fraction + case .stretchStaff(let on): stretchStaff = on case .scale(let factor): scale = factor case .graceNoteSpacing(let step): graceNoteSpacing = step case .straightFlags(let on): straightFlags = on diff --git a/Sources/ckprobe/Options.swift b/Sources/ckprobe/Options.swift index b48a7f5..cf22e59 100644 --- a/Sources/ckprobe/Options.swift +++ b/Sources/ckprobe/Options.swift @@ -20,7 +20,7 @@ struct Options { var graceSpacing: Double? /// Render once per factor and print a systems/pages table instead of a full report. var sweep: [Double]? - /// Force `%%ceolkit:justifylast false` so reported widths are natural, not stretched. + /// Force `%%stretchstaff 0` so reported widths are natural, not stretched. var natural: Bool = false /// Directory to write `page0.svg`, `page1.svg`, … into. var outputDirectory: URL? @@ -48,7 +48,7 @@ struct Options { --grace-spacing Override %%ceolkit:gracenotespacing (>= 1) before rendering. --sweep Render at each %%scale; print a systems/pages table. - --natural Force %%ceolkit:justifylast false, so system widths + --natural Force %%stretchstaff 0, so system widths are reported unstretched. --out Write page0.svg, page1.svg, … into . --json Emit JSON instead of the text report. diff --git a/Sources/ckprobe/SourceRewriter.swift b/Sources/ckprobe/SourceRewriter.swift index d47ead6..eef16c7 100644 --- a/Sources/ckprobe/SourceRewriter.swift +++ b/Sources/ckprobe/SourceRewriter.swift @@ -56,7 +56,7 @@ enum SourceRewriter { result = overriding("gracenotespacing", with: String(graceSpacing), in: result) } if options.natural { - result = overriding("justifylast", with: "false", in: result) + result = overriding(["stretchstaff"], with: "%%stretchstaff 0", in: result) } return result } diff --git a/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift b/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift index 4c2f662..4e8b14c 100644 --- a/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift +++ b/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift @@ -1,6 +1,6 @@ // CeolKit extension directive conformance tests. // Tests %%ceolkit:pipeformat, %%ceolkit:pagenumber, %%ceolkit:stemalignment, -// %%ceolkit:justifylast, %%scale / %%pagescale / %%ceolkit:scale. +// %%stretchlast / %%ceolkit:justifylast, %%scale / %%pagescale / %%ceolkit:scale. // See EXTENSIONS.md and CeolKit spec §7. import Testing import CeolKitModel @@ -296,62 +296,69 @@ struct CeolKitExtensionTests { #expect(!warnings.isEmpty) } - // MARK: %%ceolkit:justifylast + // MARK: %%stretchlast and %%ceolkit:justifylast (issue #198) - @Test("%%ceolkit:justifylast true attaches justifyLast(true) directive") - func justifylastTrue() { - let abc = """ - X:1 - T:Test - M:4/4 - L:1/4 - %%ceolkit:justifylast true - K:G - GABC| - """ - let result = parse(abc) - let tune = result.score.firstTune - let directive = tune?.directives.first(where: { - if case .justifyLast = $0.directive { return true } - return false - }) - #expect(directive != nil) - if case .justifyLast(let value) = directive?.directive { - #expect(value == true) + /// The `%%stretchlast` values attached to the first tune, in order. + private func stretchLastValues(_ abc: String) -> [Double] { + (parse(abc).score.firstTune?.directives ?? []).compactMap { + if case .stretchLast(let value) = $0.directive { return value } + return nil } } - @Test("%%ceolkit:justifylast false attaches justifyLast(false) directive") - func justifylastFalse() { - let abc = """ - X:1 - T:Test - M:4/4 - L:1/4 - %%ceolkit:justifylast false - K:G - GABC| - """ - let result = parse(abc) - let tune = result.score.firstTune - let directive = tune?.directives.first(where: { - if case .justifyLast = $0.directive { return true } - return false - }) - #expect(directive != nil) - if case .justifyLast(let value) = directive?.directive { - #expect(value == false) - } + private func tune(header line: String) -> String { + "X:1\nT:Test\nM:4/4\nL:1/4\n\(line)\nK:G\nGABC|" + } + + @Test("%%stretchlast takes abcm2ps's fraction of the line", + arguments: [("0", 0.0), ("0.25", 0.25), ("0.6", 0.6), ("1", 1.0)]) + func stretchLastFraction(payload: String, expected: Double) { + #expect(stretchLastValues(tune(header: "%%stretchlast \(payload)")) == [expected]) + } + + @Test("%%stretchlast takes the spec's logical as the two ends of the range", + arguments: [("true", 1.0), ("false", 0.0)]) + func stretchLastLogical(payload: String, expected: Double) { + #expect(stretchLastValues(tune(header: "%%stretchlast \(payload)")) == [expected]) + } + + @Test("%%stretchlast outside 0…1, or not a number, is dropped with a warning", + arguments: ["1.5", "-0.2", "yes", "0.3x"]) + func stretchLastInvalid(payload: String) { + let abc = tune(header: "%%stretchlast \(payload)") + #expect(stretchLastValues(abc).isEmpty) + let warnings = parse(abc).score.diagnostics.filter { $0.code == .invalidStretchLast } + #expect(warnings.count == 1) + } + + @Test("%%ceolkit:justifylast is a deprecated %%stretchlast 1 or 0", + arguments: [("true", 1.0), ("false", 0.0)]) + func justifyLastIsDeprecatedStretchLast(payload: String, expected: Double) { + let abc = tune(header: "%%ceolkit:justifylast \(payload)") + #expect(stretchLastValues(abc) == [expected]) + let deprecations = parse(abc).score.diagnostics.filter { $0.code == .deprecatedDirective } + #expect(deprecations.count == 1) + #expect(deprecations.first?.message.contains("%%stretchlast \(Int(expected))") == true) + } + + @Test("%%stretchstaff takes a logical", arguments: [("1", true), ("false", false)]) + func stretchStaffLogical(payload: String, expected: Bool) { + let values = (parse(tune(header: "%%stretchstaff \(payload)")).score.firstTune? + .directives ?? []).compactMap { scope -> Bool? in + if case .stretchStaff(let on) = scope.directive { return on } + return nil + } + #expect(values == [expected]) } @Test("%%ceolkit:justifylast with invalid payload emits warning and drops directive") func justifylastInvalidPayloadEmitsWarning() { let abc = "%%ceolkit:justifylast yes\nX:1\nT:T\nM:4/4\nL:1/4\nK:C\nC|" let result = parse(abc) - let warnings = result.score.diagnostics.filter { $0.code == .unknownDirective } + let warnings = result.score.diagnostics.filter { $0.code == .invalidStretchLast } #expect(!warnings.isEmpty) let hasDirective = result.score.tunes.flatMap(\.directives).contains { - if case .justifyLast = $0.directive { return true } + if case .stretchLast = $0.directive { return true } return false } #expect(!hasDirective) diff --git a/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift b/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift index 2a985f3..0131f19 100644 --- a/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift +++ b/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift @@ -200,7 +200,7 @@ struct DirectiveScopeTests { #expect(abs(beams[2] - beams[0]) < 1e-9) } - // MARK: - %%ceolkit:justifylast + // MARK: - %%stretchlast /// A one-system tune is all last system, so justifying it stretches the staff to the /// full usable width and leaving it alone stops it at the music's natural width. @@ -208,31 +208,31 @@ struct DirectiveScopeTests { config.pageSize.width - config.margins.right } - @Test("A tune header %%ceolkit:justifylast does not stretch the tune after it") - func justifyLastDoesNotLeakForward() throws { + @Test("A tune header %%stretchlast does not stretch the tune after it") + func stretchLastDoesNotLeakForward() throws { let config = SVGRenderConfig() - let runs = staffRuns(in: try render(tunes(["%%ceolkit:justifylast true", nil]), + let runs = staffRuns(in: try render(tunes(["%%stretchlast 1", nil]), config: config)) try #require(runs.count == 2) #expect(abs(runs[0].rightX - justifiedRightX(config)) < 1e-6) #expect(runs[1].rightX < justifiedRightX(config) - 1) } - @Test("A preamble %%ceolkit:justifylast governs every tune") - func preambleJustifyLastGovernsDocument() throws { + @Test("A preamble %%stretchlast governs every tune") + func preambleStretchLastGovernsDocument() throws { let config = SVGRenderConfig() let runs = staffRuns(in: try render( - tunes(preamble: "%%ceolkit:justifylast true", [nil, nil]), config: config)) + tunes(preamble: "%%stretchlast 1", [nil, nil]), config: config)) try #require(runs.count == 2) #expect(runs.allSatisfy { abs($0.rightX - justifiedRightX(config)) < 1e-6 }) } - @Test("A tune turning a preamble %%ceolkit:justifylast off does not keep it off after") - func justifyLastOverrideIsNotSticky() throws { + @Test("A tune turning a preamble %%stretchlast off does not keep it off after") + func stretchLastOverrideIsNotSticky() throws { let config = SVGRenderConfig() let runs = staffRuns(in: try render( - tunes(preamble: "%%ceolkit:justifylast true", - [nil, "%%ceolkit:justifylast false", nil]), config: config)) + tunes(preamble: "%%stretchlast 1", + [nil, "%%stretchlast 0", nil]), config: config)) try #require(runs.count == 3) #expect(abs(runs[0].rightX - justifiedRightX(config)) < 1e-6) #expect(runs[1].rightX < justifiedRightX(config) - 1) diff --git a/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift b/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift index d18cd10..03a19df 100644 --- a/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift +++ b/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift @@ -66,7 +66,7 @@ struct EventVoiceTagTests { let system = System(measures: [sized], isLastSystem: false, sourceForced: false) let justified = Justifier().justify([system], usableWidth: sized.naturalWidth * 2, - justifyLastSystem: true) + stretchLast: 1) let jm = justified[0].measures[0] #expect(jm.eventOffsets != sized.eventOffsets) // it really did stretch @@ -129,7 +129,7 @@ struct EventVoiceTagTests { let lines = voices.indices.map { LineBreaker.VoiceLine(measures: columnsPerVoice[$0]) } let groups = LineBreaker().breakIntoGroups(lines, breaks: breaks, usableWidth: 500) - let justified = Justifier().justifyGroups(groups, usableWidth: 500, justifyLastSystem: false) + let justified = Justifier().justifyGroups(groups, usableWidth: 500, stretchLast: 0) let block = TuneBlock(systemGroups: justified) let layout = VerticalLayoutEngine(config: config, metadata: metadata).layout([block]) diff --git a/Tests/CeolKitSVGRendererTests/JustifierTests.swift b/Tests/CeolKitSVGRendererTests/JustifierTests.swift index 5e05592..c4095d4 100644 --- a/Tests/CeolKitSVGRendererTests/JustifierTests.swift +++ b/Tests/CeolKitSVGRendererTests/JustifierTests.swift @@ -42,7 +42,7 @@ private let usableWidth: Double = 300 // Non-last system: final widths sum to exactly usableWidth. @Test func nonLastSystemFillsUsableWidth() { let system = makeSystem(widths: [80, 100, 60], isLast: false) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) let totalFinal = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(totalFinal - usableWidth) < 1e-9) } @@ -50,34 +50,86 @@ private let usableWidth: Double = 300 // Non-last system: every measure's finalWidth ≥ naturalWidth. @Test func nonLastSystemNeverShrinksAMeasure() { let system = makeSystem(widths: [80, 100, 60], isLast: false) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) for (jm, sm) in zip(result[0].measures, system.measures) { #expect(jm.finalWidth >= sm.naturalWidth) } } - // Last system without justifyLastSystem: finalWidth == naturalWidth for each measure. + // Last system with stretchLast 0: finalWidth == naturalWidth for each measure. @Test func lastSystemUnchangedWhenNotJustified() { let system = makeSystem(widths: [80, 100, 60], isLast: true) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) for (jm, sm) in zip(result[0].measures, system.measures) { #expect(abs(jm.finalWidth - sm.naturalWidth) < 1e-9) } } - // Last system with justifyLastSystem == true: sum equals usableWidth. + // Last system with stretchLast 1: sum equals usableWidth. @Test func lastSystemFilledWhenJustifyLastEnabled() { let system = makeSystem(widths: [80, 100, 60], isLast: true) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: true) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 1) let totalFinal = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(totalFinal - usableWidth) < 1e-9) } + // %%stretchlast F (issue #198): as abcm2ps does, the last system is stretched when its + // staff — header and music — reaches 1 − F of the line, and otherwise left short. + @Test("The last system is stretched once it fills 1 − stretchLast of the line", + arguments: [ + // (music widths, header, stretchLast, stretched?) + ([100.0, 100.0], 0.0, 0.25, false), // 0.67 of the line + ([100.0, 125.0], 0.0, 0.25, true), // exactly 0.75 + ([100.0, 100.0], 25.0, 0.25, true), // the header counts towards the fill + ([100.0, 100.0], 0.0, 0.6, true), // 0.67 ≥ 0.4 + ([10.0], 0.0, 1.0, true), // 1 stretches every last system + ([290.0], 0.0, 0.0, false), // 0 stretches none + ]) + func lastSystemStretchThreshold(widths: [Double], header: Double, stretchLast: Double, + stretched: Bool) { + let system = makeSystem(widths: widths, isLast: true) + let result = justifier.justify([system], usableWidth: usableWidth, + stretchLast: stretchLast, systemHeaderWidths: [header]) + let totalFinal = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } + let expected = stretched ? usableWidth - header : widths.reduce(0, +) + #expect(abs(totalFinal - expected) < 1e-9) + } + + // %%stretchstaff 0: no system is stretched, the last one included; an overrun is still + // squeezed onto the line. + @Test func stretchStaffOffLeavesEverySystemNatural() { + let systems = [makeSystem(widths: [100, 100], isLast: false), + makeSystem(widths: [200, 150], isLast: false), + makeSystem(widths: [280], isLast: true)] + let result = justifier.justify(systems, usableWidth: usableWidth, stretchLast: 1, + stretchStaff: false) + let totals = result.map { $0.measures.reduce(0.0) { $0 + $1.finalWidth } } + #expect(totals == [200, 300, 280]) + } + + // A last system too short to stretch is spaced as the line before it was, as abcm2ps + // does (issue #198) — never below natural, never past the line. + @Test("A short last system takes the previous line's spacing", + arguments: [ + // (previous line's widths, last line's widths, last line's expected width) + ([100.0, 100.0], [60.0], 90.0), // previous stretched 1.5× + ([150.0, 200.0], [60.0], 60.0), // previous compressed: natural + ([20.0], [100.0], 300.0), // 15× would overrun: the line + ]) + func shortLastSystemFollowsPreviousSpacing(previous: [Double], last: [Double], + expected: Double) { + let systems = [makeSystem(widths: previous, isLast: false), + makeSystem(widths: last, isLast: true)] + let result = justifier.justify(systems, usableWidth: usableWidth, stretchLast: 0) + let lastTotal = result[1].measures.reduce(0.0) { $0 + $1.finalWidth } + #expect(abs(lastTotal - expected) < 1e-9) + } + // eventOffsets are rescaled proportionally to finalWidth / naturalWidth. @Test func eventOffsetsRescaledProportionally() { let sized = sizedMeasure(width: 100, offsets: [0, 25, 50, 75]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) let jm = result[0].measures[0] let scale = jm.finalWidth / sized.naturalWidth let expected = sized.eventOffsets.map { $0 * scale } @@ -92,7 +144,7 @@ private let usableWidth: Double = 300 makeSystem(widths: [100, 100], isLast: false, sourceForced: true), makeSystem(widths: [80], isLast: true, sourceForced: false), ] - let result = justifier.justify(systems, usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify(systems, usableWidth: usableWidth, stretchLast: 0) #expect(result[0].sourceForced == true) #expect(result[0].isLastSystem == false) #expect(result[1].sourceForced == false) @@ -101,7 +153,7 @@ private let usableWidth: Double = 300 // Empty system list produces empty output. @Test func emptyInputProducesEmptyOutput() { - let result = justifier.justify([], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([], usableWidth: usableWidth, stretchLast: 0) #expect(result.isEmpty) } @@ -113,7 +165,7 @@ private let usableWidth: Double = 300 // 60 pt of music on a 300 pt line would need 5×; the cap is 3×. let system = makeSystem(widths: [60], isLast: false, staveWasSplit: true) let result = Justifier(maxStretch: 3.0) - .justify([system], usableWidth: usableWidth, justifyLastSystem: false) + .justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - 180.0) < 1e-9) } @@ -122,7 +174,7 @@ private let usableWidth: Double = 300 @Test func splitStaveSystemUnderTheCapStillFillsTheLine() { let system = makeSystem(widths: [120, 60], isLast: false, staveWasSplit: true) let result = Justifier(maxStretch: 3.0) - .justify([system], usableWidth: usableWidth, justifyLastSystem: false) + .justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - usableWidth) < 1e-9) } @@ -132,7 +184,7 @@ private let usableWidth: Double = 300 @Test func sourceBrokenSystemIsNotCapped() { let system = makeSystem(widths: [60], isLast: false, sourceForced: true, staveWasSplit: false) let result = Justifier(maxStretch: 3.0) - .justify([system], usableWidth: usableWidth, justifyLastSystem: false) + .justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - usableWidth) < 1e-9) } @@ -143,7 +195,7 @@ private let usableWidth: Double = 300 // even when it is the last system and would otherwise keep natural spacing. @Test func overrunningLastSystemIsCompressedToFit() { let system = makeSystem(widths: [153, 150], isLast: true) // 303 pt on a 300 pt line - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - usableWidth) < 1e-9) // Compression is shared out in proportion to natural width. @@ -160,7 +212,7 @@ private let usableWidth: Double = 300 makeSystem(widths: [50, 120, 90], isLast: false), ]) let result = justifier.justifyGroups([group], usableWidth: usableWidth, - justifyLastSystem: false) + stretchLast: 0) #expect(result[0].staves.count == 2) #expect(result[0].staves[0].measures.map(\.finalWidth) == result[0].staves[1].measures.map(\.finalWidth)) @@ -179,7 +231,7 @@ private let usableWidth: Double = 300 makeSystem(widths: [140, 10], isLast: false), ]) let result = justifier.justifyGroups([group], usableWidth: usableWidth, - justifyLastSystem: false) + stretchLast: 0) let widths = result[0].staves[0].measures.map(\.finalWidth) #expect(abs(widths[0] - widths[1]) < 1e-9) } @@ -188,8 +240,8 @@ private let usableWidth: Double = 300 @Test func groupOfOneMatchesTheSingleSystemPath() { let system = makeSystem(widths: [80, 100, 60], isLast: false) let viaGroups = justifier.justifyGroups([SystemGroup(staves: [system])], - usableWidth: usableWidth, justifyLastSystem: false) - let direct = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + usableWidth: usableWidth, stretchLast: 0) + let direct = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) #expect(viaGroups[0].staves[0].measures.map(\.finalWidth) == direct[0].measures.map(\.finalWidth)) } @@ -205,7 +257,7 @@ private let usableWidth: Double = 300 let naturalGap = 10.0 let sized = sizedMeasure(width: 40, offsets: [7, 17, 27], graceEventIndices: [0]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: 60, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: 60, stretchLast: 0) let jm = result[0].measures[0] let stretchedGap = jm.eventOffsets[1] - jm.eventOffsets[0] @@ -221,7 +273,7 @@ private let usableWidth: Double = 300 // new offset[2] = 7 + 10 + (27 - 7 - 10) * (43/23) = 17 + 10*(43/23) let sized = sizedMeasure(width: 40, offsets: [7, 17, 27], graceEventIndices: [0]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: 60, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: 60, stretchLast: 0) let jm = result[0].measures[0] let elasticScale = (60.0 - 7.0 - 10.0) / (40.0 - 7.0 - 10.0) // 43/23 @@ -242,7 +294,7 @@ private let usableWidth: Double = 300 let sized = sizedMeasure(width: 50, offsets: [7, 14, 24, 34, 39], graceEventIndices: [0, 3]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: 70, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: 70, stretchLast: 0) let jm = result[0].measures[0] // Both fixed gaps must be preserved exactly. diff --git a/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift b/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift index 2da6ee8..6d1d2cf 100644 --- a/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift +++ b/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift @@ -17,7 +17,7 @@ import Testing private let splitLineTune = """ %abc-2.2 %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%landscape 0 X:1 T:Kalabakan (Borneo) @@ -73,7 +73,7 @@ struct LineBreakBalanceTests { meter: tune.meter) let headerWidths = systems.enumerated().map { i, _ in i == 0 ? firstHeaderW : laterHeaderW } let justified = justifier.justify(systems, usableWidth: usableWidth, - justifyLastSystem: true, + stretchLast: 1, systemHeaderWidths: headerWidths) let targets = headerWidths.map { usableWidth - $0 } return (systems, justified, targets) diff --git a/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift b/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift index 930e6fa..714502e 100644 --- a/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift +++ b/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift @@ -236,12 +236,12 @@ struct PageOrientationTests { @Test("The systems after a mid-tune turn are broken to the new page's width") func midTuneSystemsFillTheNewWidth() throws { - // `%%ceolkit:justifylast` so that both systems are stretched to their own page's + // `%%stretchlast 1` so that both systems are stretched to their own page's // line: without it the last system of the tune is left at its natural width, which // is the same music either side of the break and so says nothing about the width it // was given. let abc = """ - %%ceolkit:justifylast true + %%stretchlast 1 X:1 T:Only M:4/4 diff --git a/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift b/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift index fd8b6d7..8577dce 100644 --- a/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift +++ b/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift @@ -251,7 +251,7 @@ struct SharedStaffMergeTests { let system = System(measures: [merged], isLastSystem: false, sourceForced: false) let justified = Justifier().justify([system], usableWidth: merged.naturalWidth * 2, - justifyLastSystem: true) + stretchLast: 1) let stretched = justified[0].measures[0] #expect(stretched.eventOffsets != merged.eventOffsets) // it really did stretch diff --git a/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift b/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift index 2d12f96..1d4b951 100644 --- a/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift @@ -132,7 +132,7 @@ struct StaffBraceTests { @Test("The music still ends on the right margin: the brace's indent came out of the line") func indentIsTakenOutOfTheLine() { var config = config - config.justifyLastSystem = true + config.stretchLast = 1 let staves = render(voices(2, "%%score {1 2}"), config: config).staves let rightMargin = config.pageSize.width - config.margins.right #expect(staves.allSatisfy { $0.left > config.margins.left }) diff --git a/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift b/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift index 137f83d..b98fea2 100644 --- a/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift @@ -75,9 +75,9 @@ struct StaffBracketTests { @Test("The music still ends on the right margin: the indent came out of the line, not off the page") func indentIsTakenOutOfTheLine() { - // `justifyLastSystem` so the one system in each tune is stretched to the full line. + // `stretchLast` 1 so the one system in each tune is stretched to the full line. var config = config - config.justifyLastSystem = true + config.stretchLast = 1 let plain = render(threeVoices(), config: config).staves let bracketed = render(threeVoices("%%score [1 2 3]"), config: config).staves diff --git a/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift b/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift index f5f8d39..30906a6 100644 --- a/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift @@ -226,7 +226,7 @@ struct StaffPlanRegionTests { @Test("Only the tune's very last system is the last one, however many regions there are") func onlyTheFinalRegionEndsTheTune() { - // `justifyLastSystem` is off by default, so only a system marked last is left short; + // A short system marked last is left short (`%%stretchlast` 0.25 by default); // a region boundary in the middle must not leave the system before it unstretched. let abc = threeVoices(header: "%%score [1 2 3]", body: "%%score [1 2]") let systems = render(abc).pages.flatMap(\.systems) diff --git a/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift b/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift index e74a2c9..eb80438 100644 --- a/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift @@ -136,7 +136,7 @@ struct StaffSpanThreadingTests { #expect(groups.allSatisfy { $0.grouping?.spans == grouping.spans }) #expect(groups.allSatisfy { $0.grouping?.barlineJoins == grouping.barlineJoins }) - let justified = Justifier().justifyGroups(groups, usableWidth: 300, justifyLastSystem: false) + let justified = Justifier().justifyGroups(groups, usableWidth: 300, stretchLast: 0) #expect(justified.allSatisfy { $0.grouping?.spans == grouping.spans }) } diff --git a/Tests/CeolKitSVGRendererTests/StyleTests.swift b/Tests/CeolKitSVGRendererTests/StyleTests.swift index 7175e11..4899036 100644 --- a/Tests/CeolKitSVGRendererTests/StyleTests.swift +++ b/Tests/CeolKitSVGRendererTests/StyleTests.swift @@ -18,7 +18,7 @@ private func parse(_ source: String) -> ParseResult { private let multitune = """ %abc-2.2 %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%writefields TRCQ true %%footer " Generated: $D" %%dateformat "%e %B %Y %H:%M" @@ -42,11 +42,11 @@ K:Cmaj a g d A | """ -// Same tune as abcTune but with %%ceolkit:justifylast true added. +// Same tune as abcTune but with %%stretchlast 1 added. private let abcTuneJustifyLast = """ %abc-2.2 %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%writefields TRC true %%footer " Generated: $D" %%straightflags false @@ -206,7 +206,7 @@ struct StyleTests { let widths = systems.enumerated().map { i, _ in i == 0 ? firstHeaderW : laterHeaderW } let justified = justifier.justify(systems, usableWidth: usableWidth, - justifyLastSystem: config.justifyLastSystem, + stretchLast: config.stretchLast, systemHeaderWidths: widths) let allSystems = engine.layout(justified).pages.flatMap { $0.systems } @@ -230,15 +230,15 @@ struct StyleTests { } } - // MARK: - %%ceolkit:justifylast + // MARK: - %%stretchlast // Shared layout helper: sizes and breaks measures into justified systems. - // `justifyLast` is passed explicitly so callers can verify directive-driven behaviour. + // `stretchLast` is passed explicitly so callers can verify directive-driven behaviour. private func justifiedSystems( score: Score, config: SVGRenderConfig, metadata: BravuraMetadata, - justifyLast: Bool + stretchLast: Double ) throws -> (systems: [JustifiedSystem], usableWidth: Double, laterHeaderWidth: Double) { let tune = try #require(score.tunes.first) let voice = try #require(tune.voices.first) @@ -280,12 +280,12 @@ struct StyleTests { let widths = systems.enumerated().map { i, _ in i == 0 ? firstHeaderW : laterHeaderW } let justified = justifier.justify(systems, usableWidth: usableWidth, - justifyLastSystem: justifyLast, + stretchLast: stretchLast, systemHeaderWidths: widths) return (justified, usableWidth, laterHeaderW) } - // When %%ceolkit:justifylast true is present the last system must be stretched + // When %%stretchlast 1 is present the last system must be stretched // to fill the full usable line width, just like every other system. @Test func lastSystemIsJustifiedWhenDirectiveIsTrue() throws { let metadata = try BravuraMetadata.load() @@ -294,22 +294,22 @@ struct StyleTests { // Verify the directive parsed correctly. let tune = try #require(score.tunes.first) - let directiveValue = tune.directives.compactMap { scope -> Bool? in - if case .justifyLast(let v) = scope.directive { return v } + let directiveValue = tune.directives.compactMap { scope -> Double? in + if case .stretchLast(let v) = scope.directive { return v } return nil }.last - let justifyLast = try #require(directiveValue as Bool?, - "%%ceolkit:justifylast true should produce a .justifyLast(true) directive on the tune") + let stretchLast = try #require(directiveValue, + "%%stretchlast 1 should produce a .stretchLast(1) directive on the tune") let (systems, usableWidth, laterHeaderW) = try justifiedSystems( - score: score, config: config, metadata: metadata, justifyLast: justifyLast) + score: score, config: config, metadata: metadata, stretchLast: stretchLast) let lastSystem = try #require(systems.last) let lastTotalW = lastSystem.measures.reduce(0.0) { $0 + $1.finalWidth } let targetWidth = usableWidth - laterHeaderW #expect(abs(lastTotalW - targetWidth) < 1.0, - "last system total width \(lastTotalW) should equal target \(targetWidth) when justifyLast is true") + "last system total width \(lastTotalW) should equal target \(targetWidth) when %%stretchlast is 1") } // MARK: Title placement @@ -666,22 +666,39 @@ struct StyleTests { "$d placeholder should be expanded using %%dateformat") } - // Without %%ceolkit:justifylast (or with false) the last system stays at its - // natural width, noticeably shorter than the full usable line width. - @Test func lastSystemIsRaggedRightByDefault() throws { + // With %%stretchlast 0 the last system stays at its natural width, noticeably shorter + // than the full usable line width. + @Test func lastSystemIsRaggedRightWithStretchLastZero() throws { let metadata = try BravuraMetadata.load() let config = SVGRenderConfig(pageSize: .letter.landscape) - // abcTune has no %%ceolkit:justifylast directive; default is false. let score = parse(abcTune).score let (systems, usableWidth, laterHeaderW) = try justifiedSystems( - score: score, config: config, metadata: metadata, justifyLast: false) + score: score, config: config, metadata: metadata, stretchLast: 0) let lastSystem = try #require(systems.last) let lastTotalW = lastSystem.measures.reduce(0.0) { $0 + $1.finalWidth } let targetWidth = usableWidth - laterHeaderW #expect(lastTotalW < targetWidth - 1.0, - "last system total width \(lastTotalW) should be less than target \(targetWidth) by default") + "last system total width \(lastTotalW) should be less than target \(targetWidth)") + } + + // abcTune's last line is four full bars, like every line above it, so the default + // %%stretchlast (0.25, abcm2ps's) stretches it to the line as well (issue #198). + @Test func fullLastSystemIsStretchedByDefault() throws { + let metadata = try BravuraMetadata.load() + let config = SVGRenderConfig(pageSize: .letter.landscape) + let score = parse(abcTune).score + + let (systems, usableWidth, laterHeaderW) = try justifiedSystems( + score: score, config: config, metadata: metadata, stretchLast: config.stretchLast) + + let lastSystem = try #require(systems.last) + let lastTotalW = lastSystem.measures.reduce(0.0) { $0 + $1.finalWidth } + let targetWidth = usableWidth - laterHeaderW + + #expect(abs(lastTotalW - targetWidth) < 1.0, + "last system total width \(lastTotalW) should equal target \(targetWidth)") } } diff --git a/Tests/CeolKitSVGRendererTests/TunebookTests.swift b/Tests/CeolKitSVGRendererTests/TunebookTests.swift index e2f5d22..d7e6ad5 100644 --- a/Tests/CeolKitSVGRendererTests/TunebookTests.swift +++ b/Tests/CeolKitSVGRendererTests/TunebookTests.swift @@ -30,7 +30,7 @@ struct TunebookTests { let abc = """ %abc-2.2 %%ceolkit:pipeformat true - %%ceolkit:justifylast true + %%stretchlast 1 %%landscape 1 X:1 T:Kalabakan (Borneo) diff --git a/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift b/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift index 13e9132..f917e56 100644 --- a/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift +++ b/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift @@ -169,7 +169,7 @@ struct VoiceLabelTests { @Test("The music still ends on the right margin: the gutter came out of the line") func gutterIsTakenOutOfTheLine() { var config = config - config.justifyLastSystem = true + config.stretchLast = 1 let rightMargin = config.pageSize.width - config.margins.right for abc in [twoVoices("", ""), twoVoices(named, alto)] { let staves = render(abc, config: config).staves diff --git a/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt b/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt index af23f23..611c1c2 100644 --- a/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt +++ b/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt @@ -870,311 +870,311 @@ - - - - - + + + + + - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt b/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt index f8632cd..b72d567 100644 --- a/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt +++ b/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt @@ -215,11 +215,11 @@ - - - - - + + + + + @@ -227,121 +227,121 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/CeolKitSVGRendererTests/tunebook.abc b/Tests/CeolKitSVGRendererTests/tunebook.abc index eb51720..48f883f 100644 --- a/Tests/CeolKitSVGRendererTests/tunebook.abc +++ b/Tests/CeolKitSVGRendererTests/tunebook.abc @@ -7,7 +7,7 @@ %%graceslurs false %%dateformat "%e %B %Y %H:%M" %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%footer "Page $P\tSVPB G4 Medley 2026\tGenerated: $D" %%landscape 1 X:1