diff --git a/CommandLine/CommandLine.swift b/CommandLine/CommandLine.swift index 8b8bfb1..3742bf3 100644 --- a/CommandLine/CommandLine.swift +++ b/CommandLine/CommandLine.swift @@ -31,7 +31,6 @@ import Foundation import SwiftDraw -import SwiftDrawDOM extension SwiftDraw.CommandLine { @@ -39,36 +38,38 @@ extension SwiftDraw.CommandLine { baseDirectory: URL = .currentDirectory) -> ExitCode { guard let config = try? parseConfiguration(from: args, baseDirectory: baseDirectory) else { - print("Invalid Syntax.", to: &.standardError) + Log.error("Invalid Syntax.") printHelp() return .error } + Log.handler = Log.standardStreams(minimumLevel: config.logLevel) + let data: Data do { data = try processImage(with: config) } catch Error.fileNotFound { - print("Failure: File does not exist.", to: &.standardError) + Log.error("Failure: File does not exist.") return .error } catch { - print("Failure:", error.localizedDescription, to: &.standardError) + Log.error("Failure: \(error.localizedDescription)") printHelp() return .error } do { try data.write(to: config.output) - print("Created: \(config.output.path)") + Log.info("Created: \(config.output.path)") } catch _ { - print("Failure: \(config.output.path)", to: &.standardError) + Log.error("Failure: \(config.output.path)") } - + return .ok } - + static func printHelp() { - print("") - print(""" + Log.info(""" + swiftdraw, version 0.29.0 copyright (c) 2026 Simon Whitty @@ -83,6 +84,7 @@ Options: --insets crop inset of output image: top,left,bottom,right --precision maximum number of decimal places --output optional path of output file + --quiet suppress warnings and progress, reporting failures only --hide-unsupported-filters hide elements with unsupported filters. diff --git a/DOM/Sources/LogSink.swift b/DOM/Sources/LogSink.swift new file mode 100644 index 0000000..f385f80 --- /dev/null +++ b/DOM/Sources/LogSink.swift @@ -0,0 +1,120 @@ +// +// LogSink.swift +// SwiftDraw +// +// Created by Simon Whitty on 26/8/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import Foundation + +/// Destination for the diagnostic messages emitted while parsing and rendering. +/// +/// Messages are written to the standard streams by default, matching the output +/// of previous versions. `SwiftDraw.Log` is the public facade of this sink. +package enum LogSink { + + package enum Level: Int, Comparable, Hashable, CaseIterable, Sendable { + case info + case warning + case error + + package static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + package typealias Handler = @Sendable (Level, String) -> Void + + /// Receives every message emitted while parsing and rendering. + /// + /// Defaults to `standardStreams()`. Assign once during startup; the property + /// is safe to access from multiple threads but messages emitted while it is + /// being replaced may reach either handler. + package static var handler: Handler { + get { + lock.lock() + defer { lock.unlock() } + return _handler + } + set { + lock.lock() + defer { lock.unlock() } + _handler = newValue + } + } + + /// Writes `info` messages to standard output and everything else to standard error. + /// - Parameter minimumLevel: messages below this level are discarded. + package static func standardStreams(minimumLevel: Level = .info) -> Handler { + makeHandler( + minimumLevel: minimumLevel, + standardOutput: { print($0) }, + standardError: { print($0, to: &.standardError) } + ) + } + + package static func makeHandler( + minimumLevel: Level, + standardOutput: @escaping @Sendable (String) -> Void, + standardError: @escaping @Sendable (String) -> Void + ) -> Handler { + { level, message in + guard level >= minimumLevel else { return } + switch level { + case .info: + standardOutput(message) + case .warning: + standardError("Warning: \(message)") + case .error: + standardError(message) + } + } + } + + private nonisolated(unsafe) static var _handler: Handler = standardStreams() + private static let lock = NSLock() +} + +package extension LogSink { + + /// Emits a message that forms part of the expected output, such as the + /// alignment insets reported by the SF Symbol renderer. + static func info(_ message: String) { + handler(.info, message) + } + + /// Emits a message about content that was rendered in a degraded way. + /// The `Warning:` prefix is applied by the handler. + static func warning(_ message: String) { + handler(.warning, message) + } + + /// Emits a message about content that could not be parsed or encoded. + static func error(_ message: String) { + handler(.error, message) + } +} diff --git a/DOM/Sources/Parser.XML.Element.swift b/DOM/Sources/Parser.XML.Element.swift index 092a967..99900cb 100644 --- a/DOM/Sources/Parser.XML.Element.swift +++ b/DOM/Sources/Parser.XML.Element.swift @@ -289,27 +289,31 @@ extension XMLParser { } package static func logParsingError(for error: any Swift.Error, filename: String?, parsing element: XML.Element? = nil) { + LogSink.error(makeParsingErrorMessage(for: error, filename: filename, parsing: element)) + } + + package static func makeParsingErrorMessage(for error: any Swift.Error, filename: String?, parsing element: XML.Element? = nil) -> String { let elementName = element.map { "<\($0.name)>" } ?? "" let filename = filename ?? "" switch error { case let XMLParser.Error.invalidDocument(error, element, line, column): let element = element.map { "<\($0)>" } ?? "" if let error = error { - print("[parsing error]", filename, element, "line:", line, "column:", column, "error:", error, to: &.standardError) + return "[parsing error] \(filename) \(element) line: \(line) column: \(column) error: \(error)" } else { - print("[parsing error]", filename, element, "line:", line, "column:", column, to: &.standardError) + return "[parsing error] \(filename) \(element) line: \(line) column: \(column)" } case let XMLParser.Error.invalidElement(name, error, line, column): if let line = line { - print("[parsing error]", filename, "<\(name)>", "line:", line, "column:", column ?? -1, "error:", error, to: &.standardError) + return "[parsing error] \(filename) <\(name)> line: \(line) column: \(column ?? -1) error: \(error)" } else { - print("[parsing error]", filename, "<\(name)>", "error:", error, to: &.standardError) + return "[parsing error] \(filename) <\(name)> error: \(error)" } default: if let location = element?.parsedLocation { - print("[parsing error]", filename, elementName, "line:", location.line, "column:", location.column, "error:", error, to: &.standardError) + return "[parsing error] \(filename) \(elementName) line: \(location.line) column: \(location.column) error: \(error)" } else { - print("[parsing error]", filename, elementName, "error:", error, to: &.standardError) + return "[parsing error] \(filename) \(elementName) error: \(error)" } } } diff --git a/DOM/Tests/LogSinkTests.swift b/DOM/Tests/LogSinkTests.swift new file mode 100644 index 0000000..a73c1d8 --- /dev/null +++ b/DOM/Tests/LogSinkTests.swift @@ -0,0 +1,195 @@ +// +// LogSinkTests.swift +// SwiftDraw +// +// Created by Simon Whitty on 26/8/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import Foundation +import Testing +@testable import SwiftDrawDOM + +struct LogSinkTests { + + @Test + func levelsAreOrderedBySeverity() { + #expect(LogSink.Level.info < LogSink.Level.warning) + #expect(LogSink.Level.warning < LogSink.Level.error) + #expect(LogSink.Level.allCases == [.info, .warning, .error]) + } + + @Test + func infoIsWrittenToStandardOutput() { + let streams = Streams() + + streams.makeHandler()(.info, "Alignment: --insets 1,2,3,4") + + #expect(streams.lines == ["out: Alignment: --insets 1,2,3,4"]) + } + + @Test + func warningIsPrefixedAndWrittenToStandardError() { + let streams = Streams() + + streams.makeHandler()(.warning, "PDF does not support transparency masks") + + #expect(streams.lines == ["err: Warning: PDF does not support transparency masks"]) + } + + @Test + func errorIsWrittenToStandardErrorWithoutPrefix() { + let streams = Streams() + + streams.makeHandler()(.error, "[parsing error] file.svg