From 52a6617603d33c1475e2ce3f357a1c387e0fc726 Mon Sep 17 00:00:00 2001 From: tyxu Date: Sat, 27 Sep 2025 22:54:34 +0800 Subject: [PATCH 1/5] Remove web related --- Package.swift | 48 +- Sources/ShaftWeb/Paragraph.swift | 73 -- Sources/ShaftWeb/ShaftHtmlCanvasView.swift | 330 ----- Sources/ShaftWeb/ShaftWebBackend.swift | 621 --------- Sources/ShaftWeb/ShaftWebFontCollection.swift | 25 - Sources/ShaftWeb/ShaftWebTimer.swift | 76 -- Sources/ShaftWeb/Text/CanvasParagraph.swift | 552 -------- .../Text/CanvasParagraphBuilder.swift | 500 -------- Sources/ShaftWeb/Text/Fragmenter.swift | 41 - Sources/ShaftWeb/Text/LayoutFragmenter.swift | 800 ------------ Sources/ShaftWeb/Text/LayoutService.swift | 1129 ----------------- .../ShaftWeb/Text/LineBreakProperties.swift | 61 - Sources/ShaftWeb/Text/LineBreaker.swift | 756 ----------- Sources/ShaftWeb/Text/Measurement.swift | 125 -- Sources/ShaftWeb/Text/Ruler.swift | 203 --- Sources/ShaftWeb/Text/TextDirection.swift | 200 --- Sources/ShaftWeb/Text/TextPaintService.swift | 95 -- Sources/ShaftWeb/Text/UnicodeRange.swift | 304 ----- .../ShaftWeb/Text/WordBreakProperties.swift | 42 - Sources/ShaftWeb/Text/WordBreaker.swift | 330 ----- Sources/ShaftWeb/Utils/BrowserDetection.swift | 266 ---- Sources/ShaftWeb/Utils/Dom.swift | 12 - Sources/ShaftWeb/Utils/StyleManager.swift | 168 --- Sources/ShaftWeb/Utils/Util.swift | 57 - Sources/WebDemo/main.swift | 21 - 25 files changed, 1 insertion(+), 6834 deletions(-) delete mode 100644 Sources/ShaftWeb/Paragraph.swift delete mode 100644 Sources/ShaftWeb/ShaftHtmlCanvasView.swift delete mode 100644 Sources/ShaftWeb/ShaftWebBackend.swift delete mode 100644 Sources/ShaftWeb/ShaftWebFontCollection.swift delete mode 100644 Sources/ShaftWeb/ShaftWebTimer.swift delete mode 100644 Sources/ShaftWeb/Text/CanvasParagraph.swift delete mode 100644 Sources/ShaftWeb/Text/CanvasParagraphBuilder.swift delete mode 100644 Sources/ShaftWeb/Text/Fragmenter.swift delete mode 100644 Sources/ShaftWeb/Text/LayoutFragmenter.swift delete mode 100644 Sources/ShaftWeb/Text/LayoutService.swift delete mode 100644 Sources/ShaftWeb/Text/LineBreakProperties.swift delete mode 100644 Sources/ShaftWeb/Text/LineBreaker.swift delete mode 100644 Sources/ShaftWeb/Text/Measurement.swift delete mode 100644 Sources/ShaftWeb/Text/Ruler.swift delete mode 100644 Sources/ShaftWeb/Text/TextDirection.swift delete mode 100644 Sources/ShaftWeb/Text/TextPaintService.swift delete mode 100644 Sources/ShaftWeb/Text/UnicodeRange.swift delete mode 100644 Sources/ShaftWeb/Text/WordBreakProperties.swift delete mode 100644 Sources/ShaftWeb/Text/WordBreaker.swift delete mode 100644 Sources/ShaftWeb/Utils/BrowserDetection.swift delete mode 100644 Sources/ShaftWeb/Utils/Dom.swift delete mode 100644 Sources/ShaftWeb/Utils/StyleManager.swift delete mode 100644 Sources/ShaftWeb/Utils/Util.swift delete mode 100644 Sources/WebDemo/main.swift diff --git a/Package.swift b/Package.swift index 8c5617a..42b5abf 100644 --- a/Package.swift +++ b/Package.swift @@ -41,13 +41,6 @@ let package = Package( // The Markdown support for Shaft .library(name: "ShaftMarkdown", targets: ["ShaftMarkdown"]), - // The Web backend and renderer for Shaft - // .library(name: "ShaftWeb", targets: ["ShaftWeb"]), - - // Companion tool for downloading Skia binaries. Will be removed in the - // future when BuilderPlugin is more mature. - .plugin(name: "CSkiaSetupPlugin", targets: ["CSkiaSetupPlugin"]), - // (experimental) Tool to build application bundles .plugin(name: "BuilderPlugin", targets: ["BuilderPlugin"]), ], @@ -82,10 +75,6 @@ let package = Package( url: "https://github.com/ShaftUI/SwiftReload.git", branch: "main" ), - // .package( - // url: "https://github.com/swiftwasm/JavaScriptKit", - // from: "0.36.0" - // ), .package( url: "https://github.com/swiftlang/swift-markdown.git", branch: "main" @@ -122,16 +111,6 @@ let package = Package( ] ), - // .executableTarget( - // name: "WebDemo", - // dependencies: [ - // "SwiftMath", - // "Shaft", - // "ShaftWeb", - // .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), - // ] - // ), - .target( name: "CSkia", dependencies: [ @@ -223,14 +202,7 @@ let package = Package( ), .target( - name: "Fetch", - // dependencies: [ - // .product( - // name: "JavaScriptKit", - // package: "JavaScriptKit", - // condition: .when(platforms: [.wasi]) - // ) - // ] + name: "Fetch" ), .target( @@ -255,15 +227,6 @@ let package = Package( name: "ShaftSDL3", condition: .when(platforms: [.linux, .windows, .macOS]) ), - // .target( - // name: "ShaftWeb", - // condition: .when(platforms: [.wasi]) - // ), - // .product( - // name: "JavaScriptEventLoop", - // package: "JavaScriptKit", - // condition: .when(platforms: [.wasi]) - // ), ], swiftSettings: [ .interoperabilityMode(.Cxx, .when(platforms: [.linux, .windows, .macOS])) @@ -291,15 +254,6 @@ let package = Package( swiftSettings: [.interoperabilityMode(.Cxx)] ), - // .target( - // name: "ShaftWeb", - // dependencies: [ - // "SwiftMath", - // "Shaft", - // "JavaScriptKit", - // ] - // ), - .target( name: "ShaftCodeHighlight", dependencies: [ diff --git a/Sources/ShaftWeb/Paragraph.swift b/Sources/ShaftWeb/Paragraph.swift deleted file mode 100644 index f0fb757..0000000 --- a/Sources/ShaftWeb/Paragraph.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Shaft - -extension FontStyle { - /// Converts a FontStyle value to its CSS equivalent. - func toCssString() -> String { - return self == .normal ? "normal" : "italic" - } -} - -extension FontWeight { - /// Converts a FontWeight value to its CSS equivalent. - func toCssString() -> String { - return fontWeightIndexToCss(fontWeightIndex: index) - } -} - -extension SpanStyle { - var effectiveFontFamily: [String] { - if let fontFamilies, !fontFamilies.isEmpty { - return fontFamilies - } - return [StyleManager.defaultFontFamily] - } - - /// Font string to be used in CSS. - /// - /// See . - var cssFontString: String { - return buildCssFontString( - fontStyle: fontStyle, - fontWeight: fontWeight, - fontSize: fontSize, - fontFamilies: effectiveFontFamily - ) - } - - /// The height style for this span. - var heightStyle: TextHeightStyle { - return TextHeightStyle( - fontFamilies: effectiveFontFamily, - fontSize: fontSize ?? StyleManager.defaultFontSize, - height: height - // TODO: Add font features and variations when supported - ) - } - -} - -func fontWeightIndexToCss(fontWeightIndex: Int = 3) -> String { - switch fontWeightIndex { - case 0: - return "100" - case 1: - return "200" - case 2: - return "300" - case 3: - return "normal" - case 4: - return "500" - case 5: - return "600" - case 6: - return "bold" - case 7: - return "800" - case 8: - return "900" - default: - assertionFailure("Failed to convert font weight \(fontWeightIndex) to CSS.") - return "" - } -} diff --git a/Sources/ShaftWeb/ShaftHtmlCanvasView.swift b/Sources/ShaftWeb/ShaftHtmlCanvasView.swift deleted file mode 100644 index ad7469f..0000000 --- a/Sources/ShaftWeb/ShaftHtmlCanvasView.swift +++ /dev/null @@ -1,330 +0,0 @@ -import JavaScriptKit -import Shaft -import SwiftMath - -public class ShaftCanvasView: Shaft.NativeView { - public init(viewID: Int, canvasElement: JSValue) { - self.viewID = viewID - self.canvasElement = canvasElement - } - - public let viewID: Int - - private var destroyed = false - - public var devicePixelRatio: Float { - Float(JSObject.global.window.devicePixelRatio.number!) - } - - public var canvasElement: JSValue - - public var physicalSize: Shaft.ISize { - if isDestroyed { - return .zero - } - let width = canvasElement.width.number! - let height = canvasElement.height.number! - return Shaft.ISize(Int(width), Int(height)) - } - - lazy private var context = canvasElement.getContext("2d") - lazy private var canvas = Canvas2DCanvas(context) - - public func render(_ layerTree: Shaft.LayerTree) { - if isDestroyed { - return - } - // dump(layerTree) - - let _ = context.setTransform(1, 0, 0, 1, 0, 0) - let _ = context.clearRect(0, 0, canvasElement.width, canvasElement.height) - - layerTree.paint( - context: LayerPaintContext(canvas: canvas) - ) - } - - public func startTextInput() { - - } - - public func stopTextInput() { - - } - - public func setComposingRect(_ rect: Shaft.Rect) { - - } - - public func setEditableSizeAndTransform(_ size: Shaft.Size, _ transform: SwiftMath.Matrix4x4f) { - - } - - public var textInputActive: Bool { false } - - public var isDestroyed: Bool { destroyed } - - func markDestroyed() { - destroyed = true - } - - public var onTextEditing: Shaft.TextEditingCallback? - - public var onTextComposed: Shaft.TextComposedCallback? - - public var title: String { - get { "" } - set {} - } -} - -class Canvas2DCanvas: Canvas { - func rotate(_ radians: Float) { - let _ = renderingContext2D.rotate(Double(radians)) - } - - public init(_ renderingContext2D: JSValue) { - self.renderingContext2D = renderingContext2D - JSObject.global.console.log(renderingContext2D) - } - - public var renderingContext2D: JSValue - - private var saveCount = 0 - - func applyPaint(_ paint: Shaft.Paint) { - renderingContext2D.strokeStyle = .string(paint.color.toCSS()) - renderingContext2D.fillStyle = .string(paint.color.toCSS()) - renderingContext2D.lineWidth = .number(Double(paint.strokeWidth)) - renderingContext2D.filter = .string(paint.maskFilter?.toCSS() ?? "") - } - - func getSaveCount() -> Int { - saveCount - } - - func save() { - saveCount += 1 - let _ = renderingContext2D.save() - } - - func saveLayer(_ bounds: Shaft.Rect, paint: Shaft.Paint?) { - saveCount += 1 - save() - } - - func restore() { - saveCount -= 1 - let _ = renderingContext2D.restore() - cachedLastCssFont = nil - } - - func translate(_ dx: Float, _ dy: Float) { - let _ = renderingContext2D.translate(dx, dy) - } - - func scale(_ sx: Float, _ sy: Float) { - let _ = renderingContext2D.scale(sx, sy) - } - - func transform(_ transform: SwiftMath.Matrix4x4f) { - let _ = renderingContext2D.transform( - transform[0, 0], - transform[1, 0], - transform[0, 1], - transform[1, 1], - transform[0, 3], - transform[1, 3] - ) - } - - func clipRect(_ rect: Shaft.Rect, _ clipOp: Shaft.ClipOp, _ doAntiAlias: Bool) { - let _ = renderingContext2D.beginPath() - let _ = renderingContext2D.rect(rect.left, rect.top, rect.width, rect.height) - let _ = renderingContext2D.clip() - } - - func clipRRect(_ rrect: Shaft.RRect, _ doAntiAlias: Bool) { - // let _ = renderingContext2D.beginPath() - // let _ = renderingContext2D.clip() - } - - func drawLine(_ p0: Shaft.Offset, _ p1: Shaft.Offset, _ paint: Shaft.Paint) { - applyPaint(paint) - let _ = renderingContext2D.beginPath() - let _ = renderingContext2D.moveTo(p0.dx, p0.dy) - let _ = renderingContext2D.lineTo(p1.dx, p1.dy) - let _ = renderingContext2D.stroke() - } - - func drawRect(_ rect: Shaft.Rect, _ paint: Shaft.Paint) { - applyPaint(paint) - - if paint.style == .fill { - let _ = renderingContext2D.fillRect(rect.left, rect.top, rect.width, rect.height) - } else { - let _ = renderingContext2D.strokeRect(rect.left, rect.top, rect.width, rect.height) - } - } - - func drawRRect(_ rrect: Shaft.RRect, _ paint: Shaft.Paint) { - applyPaint(paint) - - let _ = renderingContext2D.beginPath() - let _ = renderingContext2D.moveTo(rrect.left + rrect.tlRadiusX, rrect.top) - let _ = renderingContext2D.lineTo(rrect.right - rrect.trRadiusX, rrect.top) - let _ = renderingContext2D.arcTo( - rrect.right, - rrect.top, - rrect.right, - rrect.top + rrect.trRadiusY, - rrect.trRadiusX - ) - let _ = renderingContext2D.lineTo(rrect.right, rrect.bottom - rrect.brRadiusY) - let _ = renderingContext2D.arcTo( - rrect.right, - rrect.bottom, - rrect.right - rrect.brRadiusX, - rrect.bottom, - rrect.brRadiusY - ) - let _ = renderingContext2D.lineTo(rrect.left + rrect.blRadiusX, rrect.bottom) - let _ = renderingContext2D.arcTo( - rrect.left, - rrect.bottom, - rrect.left, - rrect.bottom - rrect.blRadiusY, - rrect.blRadiusX - ) - let _ = renderingContext2D.lineTo(rrect.left, rrect.top + rrect.tlRadiusY) - let _ = renderingContext2D.arcTo( - rrect.left, - rrect.top, - rrect.left + rrect.tlRadiusX, - rrect.top, - rrect.tlRadiusY - ) - - if paint.style == .fill { - let _ = renderingContext2D.fill() - } else { - let _ = renderingContext2D.stroke() - } - - } - - func drawDRRect(_ outer: Shaft.RRect, _ inner: Shaft.RRect, _ paint: Shaft.Paint) { - - } - - func drawCircle(_ center: Shaft.Offset, _ radius: Float, _ paint: Shaft.Paint) { - applyPaint(paint) - let _ = renderingContext2D.beginPath() - let _ = renderingContext2D.arc(center.dx, center.dy, radius, 0, 2 * Double.pi) - let _ = renderingContext2D.stroke() - } - - func drawPath(_ path: any Shaft.Path, _ paint: Shaft.Paint) { - - } - - func drawImage(_ image: any Shaft.NativeImage, _ offset: Shaft.Offset, _ paint: Shaft.Paint) { - // let _ = renderingContext2D.drawImage(image, offset.dx, offset.dy) - } - - func drawImageRect( - _ image: any Shaft.NativeImage, - _ src: Shaft.Rect, - _ dst: Shaft.Rect, - _ paint: Shaft.Paint - ) { - - } - - func drawImageNine( - _ image: any Shaft.NativeImage, - _ center: Shaft.Rect, - _ dst: Shaft.Rect, - _ paint: Shaft.Paint - ) { - - } - - func drawParagraph(_ paragraph: any Shaft.Paragraph, _ offset: Shaft.Offset) { - guard let paragraph = paragraph as? CanvasParagraph else { - return - } - - paragraph.paint(self, offset) - } - - func drawTextBlob(_ blob: any Shaft.TextBlob, _ offset: Shaft.Offset, _ paint: Shaft.Paint) { - - } - - func clear(color: Shaft.Color) { - let canvas = renderingContext2D.canvas.object! - let _ = renderingContext2D.clearRect(0, 0, canvas.width, canvas.height) - } - - private var cachedLastCssFont: String? - - func setCssFont(_ cssFont: String, textDirection: Shaft.TextDirection) { - var ctx = renderingContext2D - ctx.direction = .string(textDirection == .ltr ? "ltr" : "rtl") - - if cssFont != cachedLastCssFont { - ctx.font = .string(cssFont) - cachedLastCssFont = cssFont - } - } - - /// Draws text to the canvas starting at coordinate ([x], [y]). - /// - /// The text is drawn starting at coordinates ([x], [y]). It uses the current - /// font set by the most recent call to [setCssFont]. - func drawText( - _ text: String, - _ x: Float, - _ y: Float, - style: Shaft.PaintingStyle? = nil, - shadows: [Shaft.Shadow]? = nil - ) { - var ctx = renderingContext2D - - if let shadows = shadows { - let _ = ctx.save() - for shadow in shadows { - ctx.shadowColor = .string(shadow.color.toCSS()) - ctx.shadowBlur = .number(Double(shadow.blurRadius)) - ctx.shadowOffsetX = .number(Double(shadow.offset.dx)) - ctx.shadowOffsetY = .number(Double(shadow.offset.dy)) - - if style == .stroke { - let _ = ctx.strokeText(text, x, y) - } else { - let _ = ctx.fillText(text, x, y) - } - } - let _ = ctx.restore() - } - - if style == .stroke { - let _ = ctx.strokeText(text, x, y) - } else { - let _ = ctx.fillText(text, x, y) - } - } -} - -extension Shaft.Color { - func toCSS() -> String { - "rgba(\(red), \(green), \(blue), \(Double(alpha) / 255))" - } -} - -extension Shaft.MaskFilter { - func toCSS() -> String { - return "blur(\(sigma * 2)px)" - } -} diff --git a/Sources/ShaftWeb/ShaftWebBackend.swift b/Sources/ShaftWeb/ShaftWebBackend.swift deleted file mode 100644 index bd162ee..0000000 --- a/Sources/ShaftWeb/ShaftWebBackend.swift +++ /dev/null @@ -1,621 +0,0 @@ -import Foundation -import JavaScriptKit -import Shaft -import SwiftMath - -public class ShaftWebBackend: Backend { - private enum KeyEventPhase { - case down - case up - } - - private struct KeyMetadata { - let physical: Shaft.PhysicalKeyboardKey - let logical: Shaft.LogicalKeyboardKey - } - - public func destroyView(_ view: any Shaft.NativeView) { - guard let canvasView = view as? ShaftCanvasView else { return } - guard let removed = views.removeValue(forKey: canvasView.viewID) else { return } - removeEventListeners(for: removed.viewID) - removed.markDestroyed() - let canvas = removed.canvasElement - let _ = canvas.parentNode.object?.removeChild?(canvas) - } - - public private(set) var lifecycleState: Shaft.AppLifecycleState = .resumed - - public var onAppLifecycleStateChanged: Shaft.AppLifecycleStateCallback? - - public var locales: [Shaft.Locale] { - if let languages = JSObject.global.navigator.languages.object { - var result: [Shaft.Locale] = [] - let length = Int(languages.length.number ?? 0) - for index in 0.. JSValue - - public let onCreateElement: CreateCanvasCallback? - - public func createView() -> (any Shaft.NativeView)? { - let canvas = createCanvasElement() - let view = ShaftCanvasView( - viewID: nextViewID, - canvasElement: canvas - ) - - nextViewID += 1 - views[view.viewID] = view - addEventListeners(viewID: view.viewID, element: canvas) - - return view - } - - private static func locale(from languageTag: String) -> Shaft.Locale? { - let segments = languageTag.split(separator: "-") - guard !segments.isEmpty else { return nil } - let language = String(segments[0]) - var script: String? - var region: String? - - if segments.count == 2 { - let second = segments[1] - if second.count == 4 { - script = String(second) - } else { - region = String(second) - } - } else if segments.count >= 3 { - script = String(segments[1]) - region = String(segments[2]) - } - - return Shaft.Locale(languageCode: language, scriptCode: script, countryCode: region) - } - - private func createCanvasElement() -> JSValue { - if let onCreateElement { - return onCreateElement(nextViewID) - } - - let document = JSObject.global.document - let canvas = document.createElement("canvas") - let _ = document.body.appendChild(canvas) - return canvas - } - - private func addEventListeners(viewID: Int, element: JSValue) { - var closures: [String: JSClosure] = [:] - - let pointerDown = JSClosure { args in - let event = args[0] - let data = pointerEventToPointerData(event, viewID: viewID, change: .down) - self.onPointerData?(data) - return .undefined - } - closures["pointerdown"] = pointerDown - let _ = element.addEventListener("pointerdown", pointerDown) - - let pointerMove = JSClosure { args in - let event = args[0] - let change: PointerChange = - event.buttons.number == 0 ? .hover : .move - let data = pointerEventToPointerData(event, viewID: viewID, change: change) - self.onPointerData?(data) - return .undefined - } - closures["pointermove"] = pointerMove - let _ = element.addEventListener("pointermove", pointerMove) - - let pointerUp = JSClosure { args in - let event = args[0] - let data = pointerEventToPointerData(event, viewID: viewID, change: .up) - self.onPointerData?(data) - return .undefined - } - closures["pointerup"] = pointerUp - let _ = element.addEventListener("pointerup", pointerUp) - - let passiveClause = JSObject.global.Object.function!.new() - passiveClause.passive = .boolean(true) - let wheel = JSClosure { args in - let event = args[0] - let data = scrollEventToPointerData(event, viewID: viewID) - self.onPointerData?(data) - return .undefined - } - closures["wheel"] = wheel - let _ = element.addEventListener("wheel", wheel, passiveClause) - - pointerEventClosures[viewID] = closures - ensureGlobalKeyboardHandlers() - } - - private func removeEventListeners(for viewID: Int) { - if let closures = pointerEventClosures.removeValue(forKey: viewID) { - if let element = views[viewID]?.canvasElement { - for (event, closure) in closures { - let _ = element.removeEventListener(event, closure) - } - } - } - - if views.isEmpty { - tearDownGlobalKeyboardHandlers() - } - } - - private func registerVisibilityHandlers() { - let document = JSObject.global.document - let window = JSObject.global.window - - visibilityChangeClosure = JSClosure { _ in - self.handleVisibilityChange() - return .undefined - } - if let visibilityChangeClosure { - let _ = document.addEventListener("visibilitychange", visibilityChangeClosure) - } - - focusClosure = JSClosure { _ in - self.updateLifecycleState(.resumed) - return .undefined - } - if let focusClosure { - let _ = window.addEventListener("focus", focusClosure) - } - - blurClosure = JSClosure { _ in - self.updateLifecycleState(.inactive) - return .undefined - } - if let blurClosure { - let _ = window.addEventListener("blur", blurClosure) - } - } - - private func unregisterVisibilityHandlers() { - let document = JSObject.global.document - let window = JSObject.global.window - - if let visibilityChangeClosure { - let _ = document.removeEventListener("visibilitychange", visibilityChangeClosure) - self.visibilityChangeClosure = nil - } - - if let focusClosure { - let _ = window.removeEventListener("focus", focusClosure) - self.focusClosure = nil - } - - if let blurClosure { - let _ = window.removeEventListener("blur", blurClosure) - self.blurClosure = nil - } - - tearDownGlobalKeyboardHandlers() - } - - private func handleVisibilityChange() { - guard let hidden = JSObject.global.document.hidden.boolean else { return } - updateLifecycleState(hidden ? .hidden : .resumed) - } - - private func ensureGlobalKeyboardHandlers() { - guard keyDownClosure == nil && keyUpClosure == nil else { return } - - let keyDown = JSClosure { args in - guard let event = args.first else { return .undefined } - if self.dispatchKeyEvent(event, phase: .down) { - let _ = event.preventDefault() - } - return .undefined - } - keyDownClosure = keyDown - let _ = JSObject.global.window.addEventListener("keydown", keyDown) - - let keyUp = JSClosure { args in - guard let event = args.first else { return .undefined } - if self.dispatchKeyEvent(event, phase: .up) { - let _ = event.preventDefault() - } - return .undefined - } - keyUpClosure = keyUp - let _ = JSObject.global.window.addEventListener("keyup", keyUp) - } - - private func tearDownGlobalKeyboardHandlers() { - let window = JSObject.global.window - - if let keyDownClosure { - let _ = window.removeEventListener("keydown", keyDownClosure) - self.keyDownClosure = nil - } - - if let keyUpClosure { - let _ = window.removeEventListener("keyup", keyUpClosure) - self.keyUpClosure = nil - } - } - - private func dispatchKeyEvent(_ event: JSValue, phase: KeyEventPhase) -> Bool { - guard let keyEvent = keyEventFromJS(event, phase: phase) else { return false } - return onKeyEvent?(keyEvent) ?? false - } - - private func keyEventFromJS(_ event: JSValue, phase: KeyEventPhase) -> Shaft.KeyEvent? { - guard let meta = Self.lookupKeyMetadata(event) else { return nil } - - let type: Shaft.KeyEventType = - switch phase { - case .down: .down - case .up: .up - } - - return Shaft.KeyEvent( - type: type, - physicalKey: meta.physical, - logicalKey: meta.logical - ) - } - - private static func lookupKeyMetadata(_ event: JSValue) -> KeyMetadata? { - guard let code = event.code.string else { return nil } - - if let cached = keyCache[code] { - return cached - } - - if let mapping = codeToKeys[code] { - keyCache[code] = mapping - return mapping - } - - if let key = event.key.string?.lowercased(), let mapping = keyToLogical[key] { - keyCache[code] = mapping - return mapping - } - - return nil - } - - private static let codeToKeys: [String: KeyMetadata] = { - var mapping: [String: KeyMetadata] = [:] - - func add( - _ code: String, - _ physical: Shaft.PhysicalKeyboardKey, - _ logical: Shaft.LogicalKeyboardKey - ) { - mapping[code] = KeyMetadata(physical: physical, logical: logical) - } - - let letters: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - ("KeyA", .keyA, .keyA), ("KeyB", .keyB, .keyB), ("KeyC", .keyC, .keyC), - ("KeyD", .keyD, .keyD), ("KeyE", .keyE, .keyE), ("KeyF", .keyF, .keyF), - ("KeyG", .keyG, .keyG), ("KeyH", .keyH, .keyH), ("KeyI", .keyI, .keyI), - ("KeyJ", .keyJ, .keyJ), ("KeyK", .keyK, .keyK), ("KeyL", .keyL, .keyL), - ("KeyM", .keyM, .keyM), ("KeyN", .keyN, .keyN), ("KeyO", .keyO, .keyO), - ("KeyP", .keyP, .keyP), ("KeyQ", .keyQ, .keyQ), ("KeyR", .keyR, .keyR), - ("KeyS", .keyS, .keyS), ("KeyT", .keyT, .keyT), ("KeyU", .keyU, .keyU), - ("KeyV", .keyV, .keyV), ("KeyW", .keyW, .keyW), ("KeyX", .keyX, .keyX), - ("KeyY", .keyY, .keyY), ("KeyZ", .keyZ, .keyZ), - ] - - for entry in letters { add(entry.0, entry.1, entry.2) } - - let digits: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - ("Digit0", .digit0, .digit0), ("Digit1", .digit1, .digit1), - ("Digit2", .digit2, .digit2), ("Digit3", .digit3, .digit3), - ("Digit4", .digit4, .digit4), ("Digit5", .digit5, .digit5), - ("Digit6", .digit6, .digit6), ("Digit7", .digit7, .digit7), - ("Digit8", .digit8, .digit8), ("Digit9", .digit9, .digit9), - ] - - for entry in digits { add(entry.0, entry.1, entry.2) } - - let navigation: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - ("Enter", .enter, .enter), ("Space", .space, .space), - ("Backspace", .backspace, .backspace), ("Tab", .tab, .tab), - ("Escape", .escape, .escape), ("ArrowUp", .arrowUp, .arrowUp), - ("ArrowDown", .arrowDown, .arrowDown), ("ArrowLeft", .arrowLeft, .arrowLeft), - ("ArrowRight", .arrowRight, .arrowRight), ("Home", .home, .home), - ("End", .end, .end), ("PageUp", .pageUp, .pageUp), - ("PageDown", .pageDown, .pageDown), ("Delete", .delete, .delete), - ("Insert", .insert, .insert), - ] - - for entry in navigation { add(entry.0, entry.1, entry.2) } - - let modifiers: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - ("ShiftLeft", .shiftLeft, .shiftLeft), ("ShiftRight", .shiftRight, .shiftRight), - ("ControlLeft", .controlLeft, .controlLeft), - ("ControlRight", .controlRight, .controlRight), ("AltLeft", .altLeft, .altLeft), - ("AltRight", .altRight, .altRight), ("MetaLeft", .metaLeft, .metaLeft), - ("MetaRight", .metaRight, .metaRight), ("CapsLock", .capsLock, .capsLock), - ] - - for entry in modifiers { add(entry.0, entry.1, entry.2) } - - let symbols: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - ("Minus", .minus, .minus), ("Equal", .equal, .equal), - ("BracketLeft", .bracketLeft, .bracketLeft), - ("BracketRight", .bracketRight, .bracketRight), - ("Backslash", .backslash, .backslash), ("Semicolon", .semicolon, .semicolon), - ("Quote", .quote, .quote), ("Backquote", .backquote, .backquote), - ("Comma", .comma, .comma), ("Period", .period, .period), - ("Slash", .slash, .slash), - ] - - for entry in symbols { add(entry.0, entry.1, entry.2) } - - let numpad: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - ("NumpadEnter", .numpadEnter, .numpadEnter), ("NumpadAdd", .numpadAdd, .numpadAdd), - ("NumpadSubtract", .numpadSubtract, .numpadSubtract), - ("NumpadMultiply", .numpadMultiply, .numpadMultiply), - ("NumpadDivide", .numpadDivide, .numpadDivide), - ("NumpadDecimal", .numpadDecimal, .numpadDecimal), - ("Numpad0", .numpad0, .numpad0), ("Numpad1", .numpad1, .numpad1), - ("Numpad2", .numpad2, .numpad2), ("Numpad3", .numpad3, .numpad3), - ("Numpad4", .numpad4, .numpad4), ("Numpad5", .numpad5, .numpad5), - ("Numpad6", .numpad6, .numpad6), ("Numpad7", .numpad7, .numpad7), - ("Numpad8", .numpad8, .numpad8), ("Numpad9", .numpad9, .numpad9), - ] - - for entry in numpad { add(entry.0, entry.1, entry.2) } - - return mapping - }() - - private static let keyToLogical: [String: KeyMetadata] = { - var mapping: [String: KeyMetadata] = [:] - - func add( - _ key: String, - _ physical: Shaft.PhysicalKeyboardKey, - _ logical: Shaft.LogicalKeyboardKey - ) { - mapping[key] = KeyMetadata(physical: physical, logical: logical) - } - - let singleChars: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - (" ", .space, .space), ("-", .minus, .minus), ("=", .equal, .equal), - (",", .comma, .comma), (".", .period, .period), (";", .semicolon, .semicolon), - ("'", .quote, .quote), ("/", .slash, .slash), ("\\", .backslash, .backslash), - ("[", .bracketLeft, .bracketLeft), ("]", .bracketRight, .bracketRight), - ("`", .backquote, .backquote), - ] - - for entry in singleChars { add(entry.0, entry.1, entry.2) } - - let namedKeys: [(String, Shaft.PhysicalKeyboardKey, Shaft.LogicalKeyboardKey)] = [ - ("enter", .enter, .enter), ("backspace", .backspace, .backspace), ("tab", .tab, .tab), - ("escape", .escape, .escape), ("delete", .delete, .delete), ("home", .home, .home), - ("end", .end, .end), ("pageup", .pageUp, .pageUp), ("pagedown", .pageDown, .pageDown), - ("insert", .insert, .insert), ("arrowup", .arrowUp, .arrowUp), - ("arrowdown", .arrowDown, .arrowDown), - ("arrowleft", .arrowLeft, .arrowLeft), ("arrowright", .arrowRight, .arrowRight), - ] - - for entry in namedKeys { add(entry.0, entry.1, entry.2) } - - return mapping - }() - - private static var keyCache: [String: KeyMetadata] = [:] - - private func updateLifecycleState(_ newState: Shaft.AppLifecycleState) { - guard lifecycleState != newState else { return } - lifecycleState = newState - onAppLifecycleStateChanged?(newState) - } - - public func view(_ viewId: Int) -> (any Shaft.NativeView)? { - views[viewId] - } - - public var renderer: any Shaft.Renderer { ShaftCanvas2DRenderer.shared } - - public var onPointerData: Shaft.PointerDataCallback? - - public var onKeyEvent: Shaft.KeyEventCallback? - - public func getKeyboardState() -> [Shaft.PhysicalKeyboardKey: Shaft.LogicalKeyboardKey]? { - nil - } - - public func launchUrl(_ url: String) -> Bool { - let _ = JSObject.global.window.open(url, "_blank") - return true - } - - public var onMetricsChanged: Shaft.MetricsChangedCallback? - - public var onBeginFrame: Shaft.FrameCallback? - - public var onDrawFrame: Shaft.VoidCallback? - - public func scheduleFrame() { - let _ = JSObject.global.requestAnimationFrame!( - JSClosure { args in - let elapsed = args[0].number! - self.onBeginFrame?(Duration.milliseconds(elapsed)) - self.onDrawFrame?() - return .undefined - } - ) - } - - public func scheduleReassemble() { - updateLifecycleState(.resumed) - onReassemble?() - } - - public func run() { - // no-op - } - - public func stop() { - // no-op - } - - public var isMainThread: Bool { - true - } - - public func postTask(_ f: @escaping () -> Void) { - let _ = JSObject.global.setTimeout!( - JSClosure { _ in - f() - return .undefined - }, - 0 - ) - } - - public func createTimer( - _ delay: Duration, - repeat shouldRepeat: Bool, - callback: @escaping () -> Void - ) - -> any Shaft.Timer - { - ShaftWebTimer(delay: delay, repeats: shouldRepeat, callback: callback) - } - - public var targetPlatform: Shaft.TargetPlatform? { - nil - } - - public func createCursor(_ cursor: Shaft.SystemMouseCursor) -> (any Shaft.NativeMouseCursor)? { - nil - } -} - -public class ShaftCanvas2DRenderer: Renderer { - public static let shared = ShaftCanvas2DRenderer() - - public func createParagraphBuilder(_ style: Shaft.ParagraphStyle) -> any Shaft.ParagraphBuilder - { - Canvas2DParagraphBuilder(style: style) - } - - public func createTextBlob( - _ glyphs: [Shaft.GlyphID], - positions: [Shaft.Offset], - font: any Shaft.Font - ) -> any Shaft.TextBlob { - shouldImplement() - } - - public func decodeImageFromData(_ data: Data) -> (any Shaft.AnimatedImage)? { - shouldImplement() - - } - - public func createPath() -> any Shaft.Path { - shouldImplement() - } - - public var fontCollection: any Shaft.FontCollection { ShaftWebFontCollection.shared } -} - -private func pointerEventToPointerData( - _ event: JSValue, - viewID: Int, - change: PointerChange = .none -) - -> Shaft.PointerData -{ - let dpi = JSObject.global.devicePixelRatio.number! - let x = event.offsetX.number! - let y = event.offsetY.number! - let kind: PointerDeviceKind = - switch event.pointerType.string! { - case "mouse": .mouse - case "pen": .stylus - case "touch": .touch - default: .mouse - } - let button: PointerButtons = - switch event.button.number! { - case 0: .primaryButton - case 1: .middleMouseButton - case 2: .secondaryButton - default: .primaryButton - } - return Shaft.PointerData( - viewId: viewID, - timeStamp: Duration.milliseconds(event.timeStamp.number!), - change: change, - kind: kind, - device: Int(event.persistentDeviceId.number ?? 0), - pointerIdentifier: Int(event.pointerId.number!), - physicalX: Int(x * dpi), - physicalY: Int(y * dpi), - buttons: button, - ) -} - -private func scrollEventToPointerData( - _ event: JSValue, - viewID: Int -) -> Shaft.PointerData { - let dpi = JSObject.global.devicePixelRatio.number! - let x = event.offsetX.number! - let y = event.offsetY.number! - let deltaX = event.deltaX.number! - let deltaY = event.deltaY.number! - - return Shaft.PointerData( - viewId: viewID, - timeStamp: Duration.milliseconds(event.timeStamp.number!), - change: .none, - kind: .mouse, - signalKind: .scroll, - device: 0, - pointerIdentifier: 0, - physicalX: Int(x * dpi), - physicalY: Int(y * dpi), - buttons: .init(), - scrollDeltaX: deltaX * dpi, - scrollDeltaY: deltaY * dpi - ) -} diff --git a/Sources/ShaftWeb/ShaftWebFontCollection.swift b/Sources/ShaftWeb/ShaftWebFontCollection.swift deleted file mode 100644 index 6b62e15..0000000 --- a/Sources/ShaftWeb/ShaftWebFontCollection.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import Shaft - -public class ShaftWebFontCollection: Shaft.FontCollection { - static let shared = ShaftWebFontCollection() - - public func makeTypefaceFrom(_ data: Data) -> any Typeface { - shouldImplement() - } - - public func registerTypeface(_ typeface: any Typeface) { - shouldImplement() - } - - public func findTypeface(_ family: [String], style: FontStyle, weight: FontWeight) - -> [any Typeface] - { - shouldImplement() - } - - public func findTypefaceFor(_ codepoint: UInt32) -> (any Typeface)? { - nil - } - -} diff --git a/Sources/ShaftWeb/ShaftWebTimer.swift b/Sources/ShaftWeb/ShaftWebTimer.swift deleted file mode 100644 index d9703b0..0000000 --- a/Sources/ShaftWeb/ShaftWebTimer.swift +++ /dev/null @@ -1,76 +0,0 @@ -import JavaScriptKit -import Shaft - -public class ShaftWebTimer: Shaft.Timer { - public init(delay: Duration, repeats: Bool, callback: @escaping () -> Void) { - self.callback = callback - self.repeats = repeats - - let closure = JSClosure { [weak self] _ in - guard let self else { return .undefined } - self.handleFire() - return .undefined - } - self.jsClosure = closure - - let milliseconds = max(0, Int(delay.inMilliseconds)) - if repeats { - timerID = JSObject.global.setInterval!(closure, milliseconds) - } else { - timerID = JSObject.global.setTimeout!(closure, milliseconds) - } - } - - deinit { - cancel() - } - - public let callback: () -> Void - - private let repeats: Bool - private var timerID: JSValue? - private var jsClosure: JSClosure? - private var isCancelled = false - private var hasFired = false - - private func handleFire() { - if isCancelled { return } - - hasFired = true - callback() - - if !repeats { - clearScheduledTimer() - releaseClosure() - } - } - - public func cancel() { - guard !isCancelled else { return } - isCancelled = true - - clearScheduledTimer() - releaseClosure() - } - - public var isActive: Bool { - if isCancelled { return false } - return repeats || !hasFired - } - - private func clearScheduledTimer() { - guard let timerID else { return } - - if repeats { - let _ = JSObject.global.clearInterval!(timerID) - } else { - let _ = JSObject.global.clearTimeout!(timerID) - } - - self.timerID = nil - } - - private func releaseClosure() { - jsClosure = nil - } -} diff --git a/Sources/ShaftWeb/Text/CanvasParagraph.swift b/Sources/ShaftWeb/Text/CanvasParagraph.swift deleted file mode 100644 index 4caab0f..0000000 --- a/Sources/ShaftWeb/Text/CanvasParagraph.swift +++ /dev/null @@ -1,552 +0,0 @@ -import Shaft - -/// A paragraph made up of a flat list of text spans and placeholders. -/// -/// [CanvasParagraph] doesn't use a DOM element to represent the structure of -/// its spans and styles. Instead it uses a flat list of [ParagraphSpan] -/// objects. -class CanvasParagraph: Paragraph { - /// This class is created by the engine, and should not be instantiated - /// or extended directly. - /// - /// To create a [CanvasParagraph] object, use a [CanvasParagraphBuilder]. - init( - spans: [ParagraphSpanProtocol], - paragraphStyle: ParagraphStyle, - plainText: String, - ) { - self.spans = spans - self.paragraphStyle = paragraphStyle - self.plainText = plainText - assert(!spans.isEmpty) - } - - /// The flat list of spans that make up this paragraph. - let spans: [ParagraphSpanProtocol] - - /// General styling information for this paragraph. - let paragraphStyle: ParagraphStyle - - /// The full textual content of the paragraph. - let plainText: String - - var width: Float { - return _layoutService.width - } - - var height: Float { - return _layoutService.height - } - - var longestLine: Float { - return _layoutService.longestLine?.width ?? 0.0 - } - - var minIntrinsicWidth: Float { - return _layoutService.minIntrinsicWidth - } - - var maxIntrinsicWidth: Float { - return _layoutService.maxIntrinsicWidth - } - - var alphabeticBaseline: Float { - return _layoutService.alphabeticBaseline - } - - var ideographicBaseline: Float { - return _layoutService.ideographicBaseline - } - - var didExceedMaxLines: Bool { - return _layoutService.didExceedMaxLines - } - - var lines: [ParagraphLine] { - return _layoutService.lines - } - - /// The bounds that contain the text painted inside this paragraph. - var paintBounds: Shaft.Rect { - return _layoutService.paintBounds - } - - /// Whether this paragraph has been laid out or not. - var isLaidOut = false - - var _lastUsedConstraints: ParagraphConstraints? - - lazy var _layoutService: TextLayoutService = TextLayoutService(self) - lazy var _paintService: TextPaintService = TextPaintService(self) - - func layout(_ constraints: ParagraphConstraints) { - if constraints == _lastUsedConstraints { - return - } - - _layoutService.performLayout(constraints) - - isLaidOut = true - _lastUsedConstraints = constraints - } - - // TODO(mdebbar): Returning true means we always require a bitmap canvas. Revisit - // this decision once `CanvasParagraph` is fully implemented. - /// Whether this paragraph is doing arbitrary paint operations that require - /// a bitmap canvas, and can't be expressed in a DOM canvas. - var hasArbitraryPaint: Bool { - return true - } - - /// Paints this paragraph instance on a [canvas] at the given [offset]. - func paint(_ canvas: Canvas2DCanvas, _ offset: Offset) { - _paintService.paint(canvas: canvas, offset: offset) - } - - func getBoxesForPlaceholders() -> [TextBox] { - return _layoutService.getBoxesForPlaceholders() - } - - func getBoxesForRange( - _ start: TextIndex, - _ end: TextIndex, - boxHeightStyle: BoxHeightStyle = BoxHeightStyle.tight, - boxWidthStyle: BoxWidthStyle = BoxWidthStyle.tight - ) -> [TextBox] { - return _layoutService.getBoxesForRange(start, end, boxHeightStyle, boxWidthStyle) - } - - func getPositionForOffset(_ offset: Offset) -> TextPosition { - return _layoutService.getPositionForOffset(offset) - } - - func getClosestGlyphInfoForOffset(_ offset: Offset) -> GlyphInfo? { - return _layoutService.getClosestGlyphInfo(offset) - } - - func getGlyphInfoAt(_ codeUnitOffset: TextIndex) -> GlyphInfo? { - let lineNumber = _findLine(codeUnitOffset, 0, numberOfLines) - if lineNumber == nil { - return nil - } - let line = lines[lineNumber!] - let range = line.getCharacterRangeAt(codeUnitOffset) - if range == nil { - return nil - } - assert(line.overlapsWith(range!.start, range!.end)) - for fragment in line.fragments { - if fragment.overlapsWith(start: range!.start, end: range!.end) { - // If the grapheme cluster is split into multiple fragments (which really - // shouldn't happen but currently if they are in different TextSpans they - // don't combine), use the layout box of the first base character as its - // layout box has a better chance to be not that far-off. - let textBox = fragment.toTextBox(start: range!.start, end: range!.end) - return GlyphInfo( - graphemeClusterLayoutBounds: textBox.toRect(), - graphemeClusterCodeUnitRange: range!, - writingDirection: textBox.direction - ) - } - } - assert(false, "This should not be reachable.") - return nil - } - - func getWordBoundary(_ position: TextPosition) -> TextRange { - let characterPosition: TextIndex - switch position.affinity { - case TextAffinity.upstream: - characterPosition = position.offset - .one - case TextAffinity.downstream: - characterPosition = position.offset - } - let start = WordBreaker.prevBreakIndex( - text: plainText, - index: characterPosition.advanced(by: 1) - ) - let end = WordBreaker.nextBreakIndex(text: plainText, index: characterPosition) - return TextRange(start: start, end: end) - } - - func getLineBoundary(_ position: TextPosition) -> TextRange { - if lines.isEmpty { - return TextRange.empty - } - let lineNumber = getLineNumberAt(position.offset) - // Fallback to the last line for backward compatibility. - let line = lineNumber != nil ? lines[lineNumber!] : lines.last! - return TextRange(start: line.startIndex, end: line.endIndex - line.trailingNewlines) - } - - func computeLineMetrics() -> [LineMetrics] { - return lines.map { $0.lineMetrics } - } - - func getLineMetricsAt(line: Int) -> LineMetrics? { - return 0 <= line && line < lines.count - ? lines[line].lineMetrics - : nil - } - - var numberOfLines: Int { - return lines.count - } - - func getLineNumberAt(_ offset: TextIndex) -> Int? { - return _findLine(offset, 0, lines.count) - } - - func _findLine(_ offset: TextIndex, _ startLine: Int, _ endLine: Int) -> Int? { - assert(endLine <= lines.count) - let isOutOfBounds = - endLine <= startLine - || offset < lines[startLine].startIndex - || (endLine < numberOfLines && lines[endLine].startIndex <= offset) - if isOutOfBounds { - return nil - } - - if endLine == startLine + 1 { - assert(lines[startLine].startIndex <= offset) - assert(endLine == numberOfLines || offset < lines[endLine].startIndex) - return offset >= lines[startLine].visibleEndIndex ? nil : startLine - } - // endLine >= startLine + 2 thus we have - // startLine + 1 <= midIndex <= endLine - 1 - let midIndex = (startLine + endLine) / 2 - return _findLine(offset, midIndex, endLine) - ?? _findLine(offset, startLine, midIndex) - } - - var _disposed = false - - func dispose() { - // TODO(dnfield): It should be possible to clear resources here, but would - // need refcounting done on any surfaces/pictures holding references to this - // object. - _disposed = true - } - -} - -class ParagraphLine { - init( - hardBreak: Bool, - ascent: Float, - descent: Float, - height: Float, - width: Float, - left: Float, - baseline: Float, - lineNumber: Int, - startIndex: TextIndex, - endIndex: TextIndex, - trailingNewlines: TextIndex, - trailingSpaces: TextIndex, - spaceCount: TextIndex, - widthWithTrailingSpaces: Float, - fragments: [LayoutFragment], - textDirection: TextDirection, - paragraph: CanvasParagraph, - displayText: String? = nil - ) { - assert(trailingNewlines <= endIndex - startIndex) - self.lineMetrics = LineMetrics( - startIndex: startIndex, - endIndex: endIndex, - endIncludingNewline: endIndex + trailingNewlines, - endExcludingWhitespace: endIndex - trailingSpaces, - hardBreak: hardBreak, - ascent: ascent, - descent: descent, - unscaledAscent: ascent, - height: height, - width: width, - left: left, - baseline: baseline, - lineNumber: lineNumber - ) - self.startIndex = startIndex - self.endIndex = endIndex - self.trailingNewlines = trailingNewlines - self.trailingSpaces = trailingSpaces - self.spaceCount = spaceCount - self.widthWithTrailingSpaces = widthWithTrailingSpaces - self.fragments = fragments - self.textDirection = textDirection - self.paragraph = paragraph - self.displayText = displayText - } - - /// Metrics for this line of the paragraph. - let lineMetrics: LineMetrics - - /// The index (inclusive) in the text where this line begins. - let startIndex: TextIndex - - /// The index (exclusive) in the text where this line ends. - /// - /// When the line contains an overflow, then [endIndex] goes until the end of - /// the text and doesn't stop at the overflow cutoff. - let endIndex: TextIndex - - /// The largest visible index (exclusive) in this line. - /// - /// When the line contains an overflow, or is ellipsized at the end, this is - /// the largest index that remains visible in this line. If the entire line is - /// ellipsized, this returns [startIndex]; - lazy var visibleEndIndex: TextIndex = { - if fragments.isEmpty { - return startIndex - } - if let last = fragments.last, last is EllipsisFragment { - return fragments.dropLast().last?.end ?? startIndex - } - return fragments.last?.end ?? startIndex - }() - - /// The number of new line characters at the end of the line. - let trailingNewlines: TextIndex - - /// The number of spaces at the end of the line. - let trailingSpaces: TextIndex - - /// The number of space characters in the entire line. - let spaceCount: TextIndex - - /// The full width of the line including all trailing space but not new lines. - /// - /// The difference between [width] and [widthWithTrailingSpaces] is that - /// [widthWithTrailingSpaces] includes trailing spaces in the width - /// calculation while [width] doesn't. - /// - /// For alignment purposes for example, the [width] property is the right one - /// to use because trailing spaces shouldn't affect the centering of text. - /// But for placing cursors in text fields, we do care about trailing - /// spaces so [widthWithTrailingSpaces] is more suitable. - let widthWithTrailingSpaces: Float - - /// The fragments that make up this line. - /// - /// The fragments in the [List] are sorted by their logical order in within the - /// line. In other words, a [LayoutFragment] in the [List] will have larger - /// start and end indices than all [LayoutFragment]s that appear before it. - let fragments: [LayoutFragment] - - /// The text direction of this line, which is the same as the paragraph's. - let textDirection: TextDirection - - /// The text to be rendered on the screen representing this line. - let displayText: String? - - /// The [CanvasParagraph] this line is part of. - let paragraph: CanvasParagraph - - /// The number of space characters in the line excluding trailing spaces. - var nonTrailingSpaces: TextIndex { spaceCount - trailingSpaces } - - // Convenient getters for line metrics properties. - - var hardBreak: Bool { lineMetrics.hardBreak } - var ascent: Float { lineMetrics.ascent } - var descent: Float { lineMetrics.descent } - var unscaledAscent: Float { lineMetrics.unscaledAscent } - var height: Float { lineMetrics.height } - var width: Float { lineMetrics.width } - var left: Float { lineMetrics.left } - var baseline: Float { lineMetrics.baseline } - var lineNumber: Int { lineMetrics.lineNumber } - - func overlapsWith(_ startIndex: TextIndex, _ endIndex: TextIndex) -> Bool { - return startIndex < self.endIndex && self.startIndex < endIndex - } - - func getText(_ paragraph: CanvasParagraph) -> String { - var buffer = "" - for fragment in fragments { - buffer += fragment.getText(paragraph) - } - return buffer - } - - // This is the fallback graphme breaker that is only used if Intl.Segmenter() - // is not supported so _fromDomSegmenter can't be called. This implementation - // breaks the text into UTF-16 codepoints instead of graphme clusters. - func _fallbackGraphemeStartIterable(_ lineText: String) -> [TextIndex] { - var graphemeStarts: [TextIndex] = [] - var precededByHighSurrogate = false - for i in 0.. [TextIndex] { - var graphemeStarts = _fallbackGraphemeStartIterable(text) - // Add the end index of the fragment to the list if the text is not empty. - if !graphemeStarts.isEmpty { - graphemeStarts.append(visibleEndIndex) - } - return graphemeStarts - } - - /// This List contains an ascending sequence of UTF16 offsets that points to - /// grapheme starts within the line. Each UTF16 offset is relative to the - /// start of the paragraph, instead of the start of the line. - /// - /// For example, `graphemeStarts[n]` gives the UTF16 offset of the `n`-th - /// grapheme in the line. - lazy var graphemeStarts: [TextIndex] = { - if visibleEndIndex == startIndex { - return [] - } - let substringStart = paragraph.plainText.index( - paragraph.plainText.startIndex, - offsetBy: startIndex.utf16Offset - ) - let substringEnd = paragraph.plainText.index( - paragraph.plainText.startIndex, - offsetBy: visibleEndIndex.utf16Offset - ) - let substring = String(paragraph.plainText[substringStart.. Int { - var low = start - var high = end - assert(0 <= low) - assert(low < high) - - let lineGraphemeBreaks = graphemeStarts - assert(offset >= lineGraphemeBreaks[start]) - assert(offset < lineGraphemeBreaks.last!, "\(offset), \(lineGraphemeBreaks)") - assert(end == lineGraphemeBreaks.count || offset < lineGraphemeBreaks[end]) - while low + 2 <= high { - // high >= low + 2, so low + 1 <= mid <= high - 1 - let mid = (low + high) / 2 - switch lineGraphemeBreaks[mid].utf16Offset - offset.utf16Offset { - case let diff where diff > 0: high = mid - case let diff where diff < 0: low = mid - case 0: return mid - default: break - } - } - - assert(lineGraphemeBreaks[low] <= offset) - assert(high == lineGraphemeBreaks.count || offset < lineGraphemeBreaks[high]) - return low - } - - /// Returns the UTF-16 range of the character that encloses the code unit at - /// the given offset. - func getCharacterRangeAt(_ codeUnitOffset: TextIndex) -> TextRange? { - assert(codeUnitOffset >= self.startIndex) - if codeUnitOffset >= visibleEndIndex || graphemeStarts.isEmpty { - return nil - } - - let startIndex = graphemeStartIndexBefore(codeUnitOffset, 0, graphemeStarts.count) - assert(startIndex < graphemeStarts.count - 1) - return TextRange(start: graphemeStarts[startIndex], end: graphemeStarts[startIndex + 1]) - } - - func closestFragmentTo(_ targetFragment: LayoutFragment, searchLeft: Bool) -> LayoutFragment? { - var closestFragment: (fragment: LayoutFragment, distance: Float)? = nil - for fragment in fragments { - assert(!(fragment is EllipsisFragment)) - if fragment.start >= visibleEndIndex { - break - } - if fragment.getGraphemeStartIndexRange() == nil { - continue - } - let distance = - searchLeft - ? targetFragment.left - fragment.right - : fragment.left - targetFragment.right - let minDistance = closestFragment?.distance - switch distance { - case let d where d > 0.0 && (minDistance == nil || minDistance! > d): - closestFragment = (fragment: fragment, distance: d) - case 0.0: return fragment - default: continue - } - } - return closestFragment?.fragment - } - - /// Finds the closest [LayoutFragment] to the given horizontal offset `dx` in - /// this line, that is not an [EllipsisFragment] and contains at least one - /// grapheme start. - func closestFragmentAtOffset(_ dx: Float) -> LayoutFragment? { - if graphemeStarts.isEmpty { - return nil - } - assert(graphemeStarts.count >= 2) - var graphemeIndex = 0 - var closestFragment: (fragment: LayoutFragment, distance: Float)? = nil - for fragment in fragments { - assert(!(fragment is EllipsisFragment)) - if fragment.start >= visibleEndIndex { - break - } - if fragment.length == .zero { - continue - } - while fragment.start > graphemeStarts[graphemeIndex] { - graphemeIndex += 1 - } - let firstGraphemeStartInFragment = graphemeStarts[graphemeIndex] - if firstGraphemeStartInFragment >= fragment.end { - continue - } - let distance: Float - if dx < fragment.left { - distance = fragment.left - dx - } else if dx > fragment.right { - distance = dx - fragment.right - } else { - return fragment - } - assert(distance > 0) - - let minDistance = closestFragment?.distance - if minDistance == nil || minDistance! > distance { - closestFragment = (fragment: fragment, distance: distance) - } - } - return closestFragment?.fragment - } -} - -extension ParagraphLine: CustomStringConvertible { - var description: String { - return - "\(type(of: self))(\(startIndex.utf16Offset), \(endIndex.utf16Offset), \(lineMetrics))" - } -} - -extension ParagraphStyle { - var effectiveTextDirection: TextDirection { - return self.textDirection ?? .ltr - } - - var effectiveTextAlign: TextAlign { - return self.textAlign ?? .start - } -} diff --git a/Sources/ShaftWeb/Text/CanvasParagraphBuilder.swift b/Sources/ShaftWeb/Text/CanvasParagraphBuilder.swift deleted file mode 100644 index 3ec2312..0000000 --- a/Sources/ShaftWeb/Text/CanvasParagraphBuilder.swift +++ /dev/null @@ -1,500 +0,0 @@ -import Shaft - -private let placeholderChar = "\u{FFFC}" - -/// Builds a [CanvasParagraph] containing text with the given styling -/// information. -class Canvas2DParagraphBuilder: ParagraphBuilder { - /// Creates a [CanvasParagraphBuilder] object, which is used to create a - /// [CanvasParagraph]. - init(style: ParagraphStyle) { - self._paragraphStyle = style - self._rootStyleNode = RootStyleNode(style) - } - - private let _plainTextBuffer = StringBuilder() - private let _paragraphStyle: ParagraphStyle - - private var _spans: [ParagraphSpanProtocol] = [] - private var _styleStack: [StyleNode] = [] - - private let _rootStyleNode: RootStyleNode - private var _currentStyleNode: StyleNode { - return _styleStack.isEmpty ? _rootStyleNode : _styleStack.last! - } - - public var placeholderCount: Int { _placeholderCount } - private var _placeholderCount: Int = 0 - - public var placeholderScales: [Float] { _placeholderScales } - private var _placeholderScales: [Float] = [] - - func addPlaceholder( - width: Float, - height: Float, - alignment: PlaceholderAlignment, - scale: Float = 1.0, - baselineOffset: Float? = nil, - baseline: TextBaseline? = nil - ) { - // Require a baseline to be specified if using a baseline-based alignment. - assert( - !(alignment == PlaceholderAlignment.aboveBaseline - || alignment == PlaceholderAlignment.belowBaseline - || alignment == PlaceholderAlignment.baseline) || baseline != nil - ) - - let start = TextIndex(utf16Offset: _plainTextBuffer.buffer.utf16.count) - _plainTextBuffer.append(placeholderChar) - let end = TextIndex(utf16Offset: _plainTextBuffer.buffer.utf16.count) - - let style = _currentStyleNode.resolveStyle() - - _placeholderCount += 1 - _placeholderScales.append(scale) - _spans.append( - PlaceholderSpan( - style: style, - start: start, - end: end, - width: width * scale, - height: height * scale, - alignment: alignment, - baselineOffset: (baselineOffset ?? height) * scale, - baseline: baseline ?? TextBaseline.alphabetic - ) - ) - } - - public func pushStyle(_ style: SpanStyle) { - _styleStack.append(_currentStyleNode.createChild(style)) - } - - public func pop() { - if !_styleStack.isEmpty { - _styleStack.removeLast() - } - } - - public func addText(_ text: String) { - let start = TextIndex(utf16Offset: _plainTextBuffer.buffer.utf16.count) - _plainTextBuffer.append(text) - let end = TextIndex(utf16Offset: _plainTextBuffer.buffer.utf16.count) - - let style = _currentStyleNode.resolveStyle() - - _spans.append(ParagraphSpan(style: style, start: start, end: end)) - } - - func build() -> Shaft.Paragraph { - if _spans.isEmpty { - // In case `addText` and `addPlaceholder` were never called. - // - // We want the paragraph to always have a non-empty list of spans to match - // the expectations of the [LayoutFragmenter]. - _spans.append( - ParagraphSpan( - style: _rootStyleNode.resolveStyle(), - start: TextIndex.zero, - end: TextIndex.zero - ) - ) - } - - return CanvasParagraph( - spans: _spans, - paragraphStyle: _paragraphStyle, - plainText: _plainTextBuffer.build() - ) - } -} - -/// Represents a span in the paragraph. -/// -/// Instead of keeping spans and styles in a tree hierarchy like the framework -/// does, we flatten the structure and resolve/merge all the styles from parent -/// nodes. -/// -/// These spans are stored as a flat list in the paragraph object. -protocol ParagraphSpanProtocol { - /// The resolved style of the span. - var style: SpanStyle { get } - - /// The index of the beginning of the range of text represented by this span. - var start: TextIndex { get } - - /// The index of the end of the range of text represented by this span. - var end: TextIndex { get } -} - -/// Default implementation of ParagraphSpan -struct ParagraphSpan: ParagraphSpanProtocol { - /// Creates a [ParagraphSpan] with the given [style], representing the span of - /// text in the range between [start] and [end]. - init(style: SpanStyle, start: TextIndex, end: TextIndex) { - self.style = style - self.start = start - self.end = end - } - - /// The resolved style of the span. - let style: SpanStyle - - /// The index of the beginning of the range of text represented by this span. - let start: TextIndex - - /// The index of the end of the range of text represented by this span. - let end: TextIndex -} - -/// Holds information for a placeholder in a paragraph. -/// -/// [width], [height] and [baselineOffset] are expected to be already scaled. -struct ParagraphPlaceholder { - /// Creates a new paragraph placeholder. - init( - width: Float, - height: Float, - alignment: PlaceholderAlignment, - baselineOffset: Float, - baseline: TextBaseline - ) { - self.width = width - self.height = height - self.alignment = alignment - self.baselineOffset = baselineOffset - self.baseline = baseline - } - - /// The scaled width of the placeholder. - let width: Float - - /// The scaled height of the placeholder. - let height: Float - - /// Specifies how the placeholder rectangle will be vertically aligned with - /// the surrounding text. - let alignment: PlaceholderAlignment - - /// When the [alignment] value is [ui.PlaceholderAlignment.baseline], the - /// [baselineOffset] indicates the distance from the baseline to the top of - /// the placeholder rectangle. - let baselineOffset: Float - - /// Dictates whether to use alphabetic or ideographic baseline. - let baseline: TextBaseline -} - -/// A placeholder span in a paragraph. -struct PlaceholderSpan: ParagraphSpanProtocol { - /// Creates a new placeholder span. - init( - style: SpanStyle, - start: TextIndex, - end: TextIndex, - width: Float, - height: Float, - alignment: PlaceholderAlignment, - baselineOffset: Float, - baseline: TextBaseline - ) { - self.style = style - self.start = start - self.end = end - self.placeholder = ParagraphPlaceholder( - width: width, - height: height, - alignment: alignment, - baselineOffset: baselineOffset, - baseline: baseline - ) - } - - /// The resolved style of the span. - let style: SpanStyle - - /// The index of the beginning of the range of text represented by this span. - let start: TextIndex - - /// The index of the end of the range of text represented by this span. - let end: TextIndex - - /// The placeholder information. - let placeholder: ParagraphPlaceholder -} - -/// Represents a node in the tree of text styles pushed to [ParagraphBuilder]. -/// -/// The [ParagraphBuilder.pushText] and [ParagraphBuilder.pop] operations -/// represent the entire tree of styles in the paragraph. In our implementation, -/// we don't need to keep the entire tree structure in memory. At any point in -/// time, we only need a stack of nodes that represent the current branch in the -/// tree. The items in the stack are [StyleNode] objects. -protocol StyleNode: AnyObject { - /// Create a child for this style node. - /// - /// We are not creating a tree structure, hence there's no need to keep track - /// of the children. - func createChild(_ style: SpanStyle) -> ChildStyleNode - - var _cachedStyle: SpanStyle? { get set } - - /// Generates the final text style to be applied to the text span. - /// - /// The resolved text style is equivalent to the entire ascendent chain of - /// parent style nodes. - func resolveStyle() -> SpanStyle - - var _color: Shaft.Color? { get } - var _decoration: TextDecoration? { get } - var _decorationColor: Shaft.Color? { get } - var _decorationStyle: TextDecorationStyle? { get } - var _decorationThickness: Float? { get } - var _fontWeight: FontWeight? { get } - var _fontStyle: FontStyle? { get } - var _textBaseline: TextBaseline? { get } - var _fontFamilies: [String]? { get } - // var _fontFeatures: [FontFeature]? { get } - // var _fontVariations: [FontVariation]? { get } - var _fontSize: Float { get } - var _letterSpacing: Float? { get } - var _wordSpacing: Float? { get } - var _height: Float? { get } - var _leadingDistribution: TextLeadingDistribution? { get } - // var _locale: Locale? { get } - var _background: Paint? { get } - var _foreground: Paint? { get } - var _shadows: [Shadow]? { get } -} - -extension StyleNode { - func createChild(_ style: SpanStyle) -> ChildStyleNode { - return ChildStyleNode(parent: self, style: style) - } - - func resolveStyle() -> SpanStyle { - if let style = _cachedStyle { - return style - } - let style = SpanStyle( - color: _color, - decoration: _decoration, - decorationColor: _decorationColor, - decorationStyle: _decorationStyle, - decorationThickness: _decorationThickness, - fontWeight: _fontWeight, - fontStyle: _fontStyle, - textBaseline: _textBaseline, - fontFamilies: _fontFamilies, - fontSize: _fontSize, - letterSpacing: _letterSpacing, - wordSpacing: _wordSpacing, - height: _height, - leadingDistribution: _leadingDistribution, - background: _background, - foreground: _foreground, - shadows: _shadows - ) - _cachedStyle = style - return style - } -} - -/// Represents a non-root [StyleNode]. -class ChildStyleNode: StyleNode { - /// Creates a [ChildStyleNode] with the given [parent] and [style]. - init(parent: StyleNode, style: SpanStyle) { - self.parent = parent - self.style = style - } - - /// The parent node to be used when resolving text styles. - let parent: StyleNode - - /// The text style associated with the current node. - let style: SpanStyle - - var _cachedStyle: SpanStyle? - - // Read these properties from the TextStyle associated with this node. If the - // property isn't defined, go to the parent node. - - var _color: Shaft.Color? { - return style.color ?? ((_foreground == nil) ? parent._color : nil) - } - - var _decoration: TextDecoration? { - return style.decoration ?? parent._decoration - } - - var _decorationColor: Shaft.Color? { - return style.decorationColor ?? parent._decorationColor - } - - var _decorationStyle: TextDecorationStyle? { - return style.decorationStyle ?? parent._decorationStyle - } - - var _decorationThickness: Float? { - return style.decorationThickness.map { Float($0) } ?? parent._decorationThickness - } - - var _fontWeight: FontWeight? { - return style.fontWeight ?? parent._fontWeight - } - - var _fontStyle: FontStyle? { - return style.fontStyle ?? parent._fontStyle - } - - var _textBaseline: TextBaseline? { - return style.textBaseline ?? parent._textBaseline - } - - var _fontFamilies: [String]? { - return style.fontFamilies ?? parent._fontFamilies - } - - // var _fontFeatures: [FontFeature]? { - // return style.fontFeatures ?? parent._fontFeatures - // } - - // var _fontVariations: [FontVariation]? { - // return style.fontVariations ?? parent._fontVariations - // } - - var _fontSize: Float { - return style.fontSize.map { Float($0) } ?? parent._fontSize - } - - var _letterSpacing: Float? { - return style.letterSpacing.map { Float($0) } ?? parent._letterSpacing - } - - var _wordSpacing: Float? { - return style.wordSpacing.map { Float($0) } ?? parent._wordSpacing - } - - var _height: Float? { - if style.height == nil { - return parent._height - } - return Float(style.height!) - } - - var _leadingDistribution: TextLeadingDistribution? { - return style.leadingDistribution ?? parent._leadingDistribution - } - - // var _locale: Locale? { - // return style.locale ?? parent._locale - // } - - var _background: Paint? { - return style.background ?? parent._background - } - - var _foreground: Paint? { - return style.foreground ?? parent._foreground - } - - var _shadows: [Shadow]? { - return style.shadows ?? parent._shadows - } -} - -/// The root style node for the paragraph. -/// -/// The style of the root is derived from a [ParagraphStyle] and is the root -/// style for all spans in the paragraph. -class RootStyleNode: StyleNode { - /// Creates a [RootStyleNode] from [paragraphStyle]. - init(_ paragraphStyle: ParagraphStyle) { - self.paragraphStyle = paragraphStyle - } - - /// The style of the paragraph being built. - let paragraphStyle: ParagraphStyle - - var _cachedStyle: SpanStyle? - - var _color: Shaft.Color? { - return nil - } - - var _decoration: TextDecoration? { - return nil - } - - var _decorationColor: Shaft.Color? { - return nil - } - - var _decorationStyle: TextDecorationStyle? { - return nil - } - - var _decorationThickness: Float? { - return nil - } - - var _fontWeight: FontWeight? { - return paragraphStyle.defaultSpanStyle?.fontWeight - } - - var _fontStyle: FontStyle? { - return paragraphStyle.defaultSpanStyle?.fontStyle - } - - var _textBaseline: TextBaseline? { - return nil - } - - var _fontFamilies: [String]? { - return paragraphStyle.defaultSpanStyle?.fontFamilies - } - - // var _fontFeatures: [FontFeature]? { - // return nil - // } - - // var _fontVariations: [FontVariation]? { - // return nil - // } - - var _fontSize: Float { - return Float(paragraphStyle.defaultSpanStyle?.fontSize ?? 14.0) - } - - var _letterSpacing: Float? { - return nil - } - - var _wordSpacing: Float? { - return nil - } - - var _height: Float? { - return paragraphStyle.defaultSpanStyle?.height.map { Float($0) } - } - - var _leadingDistribution: TextLeadingDistribution? { - return nil - } - - // var _locale: Locale? { - // return paragraphStyle.locale - // } - - var _background: Paint? { - return nil - } - - var _foreground: Paint? { - return nil - } - - var _shadows: [Shadow]? { - return nil - } -} diff --git a/Sources/ShaftWeb/Text/Fragmenter.swift b/Sources/ShaftWeb/Text/Fragmenter.swift deleted file mode 100644 index a401f3b..0000000 --- a/Sources/ShaftWeb/Text/Fragmenter.swift +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Shaft - -/// Splits text into a list of `TextFragment`s. -/// -/// Various implementations can perform the fragmenting based on their own criteria. -/// -/// See: -/// -/// - `LineBreakFragmenter`: Fragments text based on line break opportunities. -/// - `BidiFragmenter`: Fragments text based on directionality. -protocol TextFragmenter { - associatedtype FragmentType: TextFragment - - /// The text to be fragmented. - var text: String { get } - - /// Performs the fragmenting of text and returns a list of `TextFragment`s. - func fragment() -> [FragmentType] -} - -/// Represents a fragment produced by `TextFragmenter`. -protocol TextFragment { - /// The start index of the fragment. - var start: TextIndex { get } - - /// The end index of the fragment. - var end: TextIndex { get } - - /// Whether this fragment's range overlaps with the range from `start` to `end`. - func overlapsWith(start: TextIndex, end: TextIndex) -> Bool -} - -extension TextFragment { - func overlapsWith(start: TextIndex, end: TextIndex) -> Bool { - return start < self.end && self.start < end - } -} diff --git a/Sources/ShaftWeb/Text/LayoutFragmenter.swift b/Sources/ShaftWeb/Text/LayoutFragmenter.swift deleted file mode 100644 index 84cc261..0000000 --- a/Sources/ShaftWeb/Text/LayoutFragmenter.swift +++ /dev/null @@ -1,800 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Shaft - -/// Splits [text] into fragments that are ready to be laid out by -/// [TextLayoutService]. -/// -/// This fragmenter takes into account line breaks, directionality and styles. -class LayoutFragmenter: TextFragmenter { - init(_ text: String, _ paragraphSpans: [ParagraphSpanProtocol]) { - self.text = text - self.paragraphSpans = paragraphSpans - } - - let text: String - let paragraphSpans: [ParagraphSpanProtocol] - - func fragment() -> [LayoutFragment] { - var fragments: [LayoutFragment] = [] - - var fragmentStart = TextIndex.zero - - let lineBreakFragmenter = createLineBreakFragmenter(text: text) - var lineBreakFragments = lineBreakFragmenter.fragment().makeIterator() - - let bidiFragmenter = BidiFragmenter(text) - var bidiFragments = bidiFragmenter.fragment().makeIterator() - - var spans = paragraphSpans.makeIterator() - - guard var currentLineBreakFragment = lineBreakFragments.next(), - var currentBidiFragment = bidiFragments.next(), - var currentSpan = spans.next() - else { - return fragments - } - - while true { - let fragmentEnd = min( - currentLineBreakFragment.end, - min( - currentBidiFragment.end, - currentSpan.end - ) - ) - - let distanceFromLineBreak = currentLineBreakFragment.end - fragmentEnd - - let lineBreakType = - distanceFromLineBreak == .zero - ? currentLineBreakFragment.type - : LineBreakType.prohibited - - let trailingNewlines = currentLineBreakFragment.trailingNewlines - distanceFromLineBreak - let trailingSpaces = currentLineBreakFragment.trailingSpaces - distanceFromLineBreak - - let fragmentLength = fragmentEnd - fragmentStart - fragments.append( - LayoutFragment( - fragmentStart, - fragmentEnd, - lineBreakType, - currentBidiFragment.textDirection, - currentBidiFragment.fragmentFlow, - currentSpan, - trailingNewlines: max(.zero, min(trailingNewlines, fragmentLength)), - trailingSpaces: max(.zero, min(trailingSpaces, fragmentLength)) - ) - ) - - fragmentStart = fragmentEnd - - var moved = false - if currentLineBreakFragment.end == fragmentEnd { - if let next = lineBreakFragments.next() { - moved = true - currentLineBreakFragment = next - } - } - if currentBidiFragment.end == fragmentEnd { - if let next = bidiFragments.next() { - moved = true - currentBidiFragment = next - } - } - if currentSpan.end == fragmentEnd { - if let next = spans.next() { - moved = true - currentSpan = next - } - } - - // Once we reached the end of all fragments, exit the loop. - if !moved { - break - } - } - - return fragments - } -} - -/// A protocol that combines text fragment with additional layout properties. -protocol CombinedFragment: TextFragment, AnyObject { - var type: LineBreakType { get } - var textDirection: TextDirection? { get set } - var fragmentFlow: FragmentFlow { get } - var span: ParagraphSpanProtocol { get } - var trailingNewlines: TextIndex { get } - var trailingSpaces: TextIndex { get } -} - -class LayoutFragment: CombinedFragment, FragmentMetrics, FragmentPosition, FragmentBox { - var start: TextIndex - var end: TextIndex - var type: LineBreakType - var textDirection: TextDirection? - var fragmentFlow: FragmentFlow - var span: ParagraphSpanProtocol - var trailingNewlines: TextIndex - var trailingSpaces: TextIndex - - // FragmentMetrics properties - var spanometer: Spanometer! - var ascent: Float = 0.0 - var descent: Float = 0.0 - var widthExcludingTrailingSpaces: Float = 0.0 - var widthIncludingTrailingSpaces: Float = 0.0 - var extraWidthForJustification: Float = 0.0 - - // FragmentPosition properties - var startOffset: Float = 0.0 - var line: ParagraphLine! - - init( - _ start: TextIndex, - _ end: TextIndex, - _ type: LineBreakType, - _ textDirection: TextDirection?, - _ fragmentFlow: FragmentFlow, - _ span: ParagraphSpanProtocol, - trailingNewlines: TextIndex, - trailingSpaces: TextIndex - ) { - self.start = start - self.end = end - self.type = type - self.textDirection = textDirection - self.fragmentFlow = fragmentFlow - self.span = span - self.trailingNewlines = trailingNewlines - self.trailingSpaces = trailingSpaces - } - - var length: TextIndex { return end - start } - var isSpaceOnly: Bool { return length == trailingSpaces } - var isPlaceholder: Bool { return span is PlaceholderSpan } - var isBreak: Bool { return type != .prohibited } - var isHardBreak: Bool { return type == .mandatory || type == .endOfText } - var style: SpanStyle { return span.style } - - /// Returns the substring from paragraph that corresponds to this fragment, - /// excluding new line characters. - func getText(_ paragraph: CanvasParagraph) -> String { - return (start..<(end - trailingNewlines)).textInside(paragraph.plainText) - } - - /// Splits this fragment into two fragments with the split point being the - /// given index. - func split(_ index: TextIndex) -> [LayoutFragment?] { - assert(start <= index) - assert(index <= end) - - if start == index { - return [nil, self] - } - - if end == index { - return [self, nil] - } - - // The length of the second fragment after the split. - let secondLength = end - index - - // Trailing spaces/new lines go to the second fragment. Any left over goes - // to the first fragment. - let secondTrailingNewlines = min(trailingNewlines, secondLength) - let secondTrailingSpaces = min(trailingSpaces, secondLength) - - return [ - LayoutFragment( - start, - index, - .prohibited, - textDirection, - fragmentFlow, - span, - trailingNewlines: trailingNewlines - secondTrailingNewlines, - trailingSpaces: trailingSpaces - secondTrailingSpaces - ), - LayoutFragment( - index, - end, - type, - textDirection, - fragmentFlow, - span, - trailingNewlines: secondTrailingNewlines, - trailingSpaces: secondTrailingSpaces - ), - ] - } -} - -protocol FragmentMetrics: AnyObject { - /// The spanometer used for measuring text. - var spanometer: Spanometer! { get set } - - /// The rise from the baseline as calculated from the font and style for this text. - var ascent: Float { get set } - - /// The drop from the baseline as calculated from the font and style for this text. - var descent: Float { get set } - - /// The width of the measured text, not including trailing spaces. - var widthExcludingTrailingSpaces: Float { get set } - - /// The width of the measured text, including any trailing spaces. - var widthIncludingTrailingSpaces: Float { get set } - - /// Extra width added for justification. - var extraWidthForJustification: Float { get set } - - /// The total height as calculated from the font and style for this text. - var height: Float { get } - - /// The width of trailing spaces in the fragment. - var widthOfTrailingSpaces: Float { get } - - /// Set measurement values for the fragment. - func setMetrics( - _ spanometer: Spanometer, - ascent: Float, - descent: Float, - widthExcludingTrailingSpaces: Float, - widthIncludingTrailingSpaces: Float - ) -} - -extension FragmentMetrics { - var height: Float { - return ascent + descent - } - - var widthOfTrailingSpaces: Float { - return widthIncludingTrailingSpaces - widthExcludingTrailingSpaces - } - - func setMetrics( - _ spanometer: Spanometer, - ascent: Float, - descent: Float, - widthExcludingTrailingSpaces: Float, - widthIncludingTrailingSpaces: Float - ) { - self.spanometer = spanometer - self.ascent = ascent - self.descent = descent - self.widthExcludingTrailingSpaces = widthExcludingTrailingSpaces - self.widthIncludingTrailingSpaces = widthIncludingTrailingSpaces - } -} - -/// Encapsulates positioning of the fragment relative to the line. -/// -/// The coordinates are all relative to the line it belongs to. For example, -/// [left] is the distance from the left edge of the line to the left edge of -/// the fragment. -/// -/// This is what the various measurements/coordinates look like for a fragment -/// in an LTR paragraph: -/// -/// *------------------------line.width-----------------* -/// *---width----* -/// ┌─────────────────┬────────────┬────────────────────┐ -/// │ │--FRAGMENT--│ │ -/// └─────────────────┴────────────┴────────────────────┘ -/// *---startOffset---* -/// *------left-------* -/// *--------endOffset-------------* -/// *----------right---------------* -/// -/// -/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because -/// the line starts from the right. Here's what they look like: -/// -/// *------------------------line.width-----------------* -/// *---width----* -/// ┌─────────────────┬────────────┬────────────────────┐ -/// │ │--FRAGMENT--│ │ -/// └─────────────────┴────────────┴────────────────────┘ -/// *----startOffset-----* -/// *------left-------* -/// *-----------endOffset-------------* -/// *----------right---------------* -/// -protocol FragmentPosition: CombinedFragment, FragmentMetrics, AnyObject { - /// The distance from the beginning of the line to the beginning of the fragment. - var startOffset: Float { get set } - - /// The width of the line that contains this fragment. - var line: ParagraphLine! { get set } - - /// The distance from the beginning of the line to the end of the fragment. - var endOffset: Float { get } - - /// The distance from the left edge of the line to the left edge of the fragment. - var left: Float { get } - - /// The distance from the left edge of the line to the right edge of the fragment. - var right: Float { get } - - /// Set the horizontal position of this fragment relative to the [line] that - /// contains it. - func setPosition(startOffset: Float, textDirection: TextDirection) - - /// Adjust the width of this fragment for paragraph justification. - func justifyTo(paragraphWidth: Float) -} - -extension FragmentPosition { - var endOffset: Float { - return startOffset + widthIncludingTrailingSpaces - } - - var left: Float { - return line.textDirection == .ltr - ? startOffset - : line.width - endOffset - } - - var right: Float { - return line.textDirection == .ltr - ? endOffset - : line.width - startOffset - } - - /// Set the horizontal position of this fragment relative to the [line] that - /// contains it. - func setPosition(startOffset: Float, textDirection: TextDirection) { - self.startOffset = startOffset - self.textDirection = textDirection - } - - func justifyTo(paragraphWidth: Float) { - // Only justify this fragment if it's not a trailing space in the line. - if end > line.endIndex - line.trailingSpaces { - // Don't justify fragments that are part of trailing spaces of the line. - return - } - - if trailingSpaces == .zero { - // If this fragment has no spaces, there's nothing to justify. - return - } - - let justificationTotal = paragraphWidth - line.width - let justificationPerSpace = justificationTotal / Float(line.nonTrailingSpaces.utf16Offset) - extraWidthForJustification = justificationPerSpace * Float(trailingSpaces.utf16Offset) - } -} - -/// Encapsulates calculations related to the bounding box of the fragment -/// relative to the paragraph. -protocol FragmentBox: FragmentMetrics, FragmentPosition { - /// The distance from the top of the paragraph to the top edge of the fragment. - var top: Float { get } - - /// The distance from the top of the paragraph to the bottom edge of the fragment. - var bottom: Float { get } - - /// Whether the trailing spaces of this fragment are part of trailing - /// spaces of the line containing the fragment. - var isPartOfTrailingSpacesInLine: Bool { get } - - /// Returns a TextBox for the purpose of painting this fragment. - /// - /// The coordinates of the resulting TextBox are relative to the - /// paragraph, not to the line. - /// - /// Trailing spaces in each line aren't painted on the screen, so they are - /// excluded from the resulting text box. - func toPaintingTextBox() -> TextBox - - /// Returns a TextBox representing this fragment. - /// - /// The coordinates of the resulting TextBox are relative to the - /// paragraph, not to the line. - /// - /// As opposed to toPaintingTextBox, the resulting text box from this method - /// includes trailing spaces of the fragment. - func toTextBox(start: TextIndex?, end: TextIndex?) -> TextBox - - /// Returns the text position within this fragment's range that's closest to - /// the given x offset. - /// - /// The x offset is expected to be relative to the left edge of the fragment. - func getPositionForX(_ x: Float) -> TextPosition - - /// Whether the first codepoints of this fragment is not a valid grapheme start, - /// and belongs in the the previous fragment. - var hasLeadingBrokenGrapheme: Bool { get } - - /// Returns the GlyphInfo of the character in the fragment that is closest to - /// the given offset x. - func getClosestCharacterBox(_ x: Float) -> GlyphInfo -} - -extension FragmentBox { - var top: Float { - return line.baseline - ascent - } - - var bottom: Float { - return line.baseline + descent - } - - var isPartOfTrailingSpacesInLine: Bool { - return end > line.endIndex - line.trailingSpaces - } - - func toPaintingTextBox() -> TextBox { - if isPartOfTrailingSpacesInLine { - // For painting, we exclude the width of trailing spaces from the box. - return textDirection == .ltr - ? TextBox( - left: line.left + left, - top: top, - right: line.left + right - widthOfTrailingSpaces, - bottom: bottom, - direction: textDirection! - ) - : TextBox( - left: line.left + left + widthOfTrailingSpaces, - top: top, - right: line.left + right, - bottom: bottom, - direction: textDirection! - ) - } - return TextBox( - left: line.left + left, - top: top, - right: line.left + right, - bottom: bottom, - direction: textDirection! - ) - } - - func toTextBox(start: TextIndex? = nil, end: TextIndex? = nil) -> TextBox { - let startIndex = start ?? self.start - let endIndex = end ?? self.end - - if startIndex <= self.start && endIndex >= (self.end - trailingNewlines) { - return TextBox( - left: line.left + left, - top: top, - right: line.left + right, - bottom: bottom, - direction: textDirection! - ) - } - return intersect(startIndex, endIndex) - } - - /// Performs the intersection of this fragment with the range given by start and - /// end indices, and returns a TextBox representing that intersection. - /// - /// The coordinates of the resulting TextBox are relative to the - /// paragraph, not to the line. - func intersect(_ start: TextIndex, _ end: TextIndex) -> TextBox { - // `intersect` should only be called when there's an actual intersection. - assert(start > self.start || end < self.end) - - let before: Float - if start <= self.start { - before = 0.0 - } else { - spanometer.currentSpan = span - before = spanometer.measureRange(start: self.start, end: start) - } - - let after: Float - if end >= (self.end - trailingNewlines) { - after = 0.0 - } else { - spanometer.currentSpan = span - after = spanometer.measureRange( - start: end, - end: self.end - trailingNewlines - ) - } - - let (left, right): (Float, Float) - if textDirection == .ltr { - // Example: let's say the text is "Loremipsum" and we want to get the box - // for "rem". In this case, `before` is the width of "Lo", and `after` - // is the width of "ipsum". - // - // Here's how the measurements/coordinates look like: - // - // before after - // |----| |----------| - // +---------------------+ - // | L o r e m i p s u m | - // +---------------------+ - // this.left ^ ^ this.right - left = self.left + before - right = self.right - after - } else { - // Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from - // right to left). Say we want to get the box for "brew". The `before` is - // the width of "He", and `after` is the width of "_text". - // - // after before - // |----------| |----| - // +-----------------------+ - // | t x e t _ w e r b e H | - // +-----------------------+ - // this.left ^ ^ this.right - // - // Notice how `before` and `after` are reversed in the RTL example. That's - // because the text flows from right to left. - left = self.left + after - right = self.right - before - } - - return TextBox( - left: line.left + left, - top: top, - right: line.left + right, - bottom: bottom, - direction: textDirection! - ) - } - - func getPositionForX(_ x: Float) -> TextPosition { - let adjustedX = makeXDirectionAgnostic(x) - - let startIndex = start - let endIndex = end - trailingNewlines - - // Check some special cases to return the result quicker. - let length = endIndex - startIndex - if length == .zero { - return TextPosition(offset: startIndex) - } - if length == .one { - // Find out if `x` is closer to `startIndex` or `endIndex`. - let distanceFromStart = adjustedX - let distanceFromEnd = widthIncludingTrailingSpaces - adjustedX - return distanceFromStart < distanceFromEnd - ? TextPosition(offset: startIndex) - : TextPosition(offset: endIndex, affinity: .upstream) - } - - spanometer.currentSpan = span - // The resulting `cutoff` is the index of the character where the `x` offset - // falls. We should return the text position of either `cutoff` or - // `cutoff + 1` depending on which one `x` is closer to. - // - // offset x - // ↓ - // "A B C D E F" - // ↑ - // cutoff - let cutoff = spanometer.forceBreak( - start: startIndex, - end: endIndex, - availableWidth: adjustedX, - allowEmpty: true - ) - - if cutoff == endIndex { - return TextPosition( - offset: cutoff, - affinity: .upstream - ) - } - - let lowWidth = spanometer.measureRange(start: startIndex, end: cutoff) - let highWidth = spanometer.measureRange(start: startIndex, end: cutoff.advanced(by: 1)) - - // See if `x` is closer to `cutoff` or `cutoff + 1`. - if adjustedX - lowWidth < highWidth - adjustedX { - // The offset is closer to cutoff. - return TextPosition(offset: cutoff) - } else { - // The offset is closer to cutoff + 1. - return TextPosition( - offset: cutoff.advanced(by: 1), - affinity: .upstream - ) - } - } - /// Transforms the [x] coordinate to be direction-agnostic. - /// - /// The X (input) is relative to the [left] edge of the fragment, and this - /// method returns an X' (output) that's relative to beginning of the text. - /// - /// Here's how it looks for a fragment with LTR content: - /// - /// *------------------------line width------------------* - /// *-----X (input) - /// ┌───────────┬────────────────────────┬───────────────┐ - /// │ │ ---text-direction----> │ │ - /// └───────────┴────────────────────────┴───────────────┘ - /// *-----X' (output) - /// *---left----* - /// *---------------right----------------* - /// - /// - /// And here's how it looks for a fragment with RTL content: - /// - /// *------------------------line width------------------* - /// *-----X (input) - /// ┌───────────┬────────────────────────┬───────────────┐ - /// │ │ <---text-direction---- │ │ - /// └───────────┴────────────────────────┴───────────────┘ - /// (output) X'-----------------* - /// *---left----* - /// *---------------right----------------* - /// - func makeXDirectionAgnostic(_ x: Float) -> Float { - if textDirection == .rtl { - return widthIncludingTrailingSpaces - x - } - return x - } - - func getClosestCharacterBox(_ x: Float) -> GlyphInfo { - assert(end > start) - guard let graphemeStartIndexRange = getGraphemeStartIndexRange() else { - fatalError("Fragment must have at least one grapheme start") - } - - let (rangeStart, rangeEnd) = graphemeStartIndexRange - return getClosestCharacterInRange(x, rangeStart, rangeEnd) - } - - func getGraphemeStartIndexRange() -> (Int, Int)? { - if end == start { - return nil - } - - let lineGraphemeBreaks = line.graphemeStarts - assert(end > start) - assert(!lineGraphemeBreaks.isEmpty) - - let startIndex = line.graphemeStartIndexBefore(start, 0, lineGraphemeBreaks.count) - let endIndex = - end == start.advanced(by: 1) - ? startIndex + 1 - : line.graphemeStartIndexBefore( - end.advanced(by: -1), - startIndex, - lineGraphemeBreaks.count - ) + 1 - - let firstGraphemeStart = lineGraphemeBreaks[startIndex] - return firstGraphemeStart > start - ? (endIndex == startIndex + 1 ? nil : (startIndex + 1, endIndex)) - : (startIndex, endIndex) - } - - var hasLeadingBrokenGrapheme: Bool { - guard let graphemeStartIndexRange = getGraphemeStartIndexRange() else { - return true - } - let graphemeStartIndexRangeStart = graphemeStartIndexRange.0 - return line.graphemeStarts[graphemeStartIndexRangeStart] != start - } - - func getClosestCharacterInRange(_ x: Float, _ startIndex: Int, _ endIndex: Int) -> GlyphInfo { - let graphemeStartIndices = line.graphemeStarts - let fullRange = TextRange( - start: graphemeStartIndices[startIndex], - end: graphemeStartIndices[endIndex] - ) - let fullBox = toTextBox(start: fullRange.start, end: fullRange.end) - - if startIndex + 1 == endIndex { - return GlyphInfo( - graphemeClusterLayoutBounds: fullBox.toRect(), - graphemeClusterCodeUnitRange: fullRange, - writingDirection: fullBox.direction - ) - } - - assert(startIndex + 1 < endIndex) - let left = fullBox.left - let right = fullBox.right - - // The toTextBox call is potentially expensive so we'll try reducing the - // search steps with a binary search. - // - // x ∈ (left, right), - if left < x && x < right { - let midIndex = (startIndex + endIndex) / 2 - // endIndex >= startIndex + 2, so midIndex >= start + 1 - let firstHalf = getClosestCharacterInRange(x, startIndex, midIndex) - if firstHalf.graphemeClusterLayoutBounds.left < x - && x < firstHalf.graphemeClusterLayoutBounds.right - { - return firstHalf - } - // startIndex <= endIndex - 2, so midIndex <= endIndex - 1 - let secondHalf = getClosestCharacterInRange(x, midIndex, endIndex) - if secondHalf.graphemeClusterLayoutBounds.left < x - && x < secondHalf.graphemeClusterLayoutBounds.right - { - return secondHalf - } - // Neither box clips the given x. This is supposed to be rare. - let distanceToFirst = abs( - x - - min( - max(firstHalf.graphemeClusterLayoutBounds.left, x), - firstHalf.graphemeClusterLayoutBounds.right - ) - ) - let distanceToSecond = abs( - x - - min( - max(secondHalf.graphemeClusterLayoutBounds.left, x), - secondHalf.graphemeClusterLayoutBounds.right - ) - ) - return distanceToFirst > distanceToSecond ? firstHalf : secondHalf - } - - // x ∉ (left, right), it's either the first character or the last, since - // there can only be one writing direction in the fragment. - let range: TextRange - switch (fullBox.direction, x <= left) { - case (.ltr, true), (.rtl, false): - range = TextRange( - start: graphemeStartIndices[startIndex], - end: graphemeStartIndices[startIndex + 1] - ) - case (.ltr, false), (.rtl, true): - range = TextRange( - start: graphemeStartIndices[endIndex - 1], - end: graphemeStartIndices[endIndex] - ) - } - - assert(!range.isCollapsed) - let box = toTextBox(start: range.start, end: range.end) - return GlyphInfo( - graphemeClusterLayoutBounds: box.toRect(), - graphemeClusterCodeUnitRange: range, - writingDirection: box.direction - ) - } -} - -class EllipsisFragment: LayoutFragment { - init( - index: TextIndex, - span: ParagraphSpanProtocol - ) { - super.init( - index, - index, - LineBreakType.endOfText, - nil, - // The ellipsis is always at the end of the line, so it can't be - // sandwiched. This means it'll always follow the paragraph direction. - FragmentFlow.sandwich, - span, - trailingNewlines: TextIndex.zero, - trailingSpaces: TextIndex.zero - ) - } - - override var isSpaceOnly: Bool { false } - - override var isPlaceholder: Bool { false } - - override func getText(_ paragraph: CanvasParagraph) -> String { - return paragraph.paragraphStyle.ellipsis! - } - - override func split(_ index: TextIndex) -> [LayoutFragment?] { - fatalError("Cannot split an EllipsisFragment") - } -} diff --git a/Sources/ShaftWeb/Text/LayoutService.swift b/Sources/ShaftWeb/Text/LayoutService.swift deleted file mode 100644 index 170b3d1..0000000 --- a/Sources/ShaftWeb/Text/LayoutService.swift +++ /dev/null @@ -1,1129 +0,0 @@ -import JavaScriptKit -import Shaft - -/// A single canvas2d context to use for all text measurements. -var textContext: JSValue = { - // We don't use this canvas to draw anything, so let's make it as small as - // possible to save memory. - return createDomCanvasElement(width: 0, height: 0).getContext("2d") -}() - -/// The last font used in the [textContext]. -private var _lastContextFont: String? - -/// Performs layout on a [CanvasParagraph]. -/// -/// It uses a [DomCanvasElement] to measure text. -class TextLayoutService { - init(_ paragraph: CanvasParagraph) { - self.paragraph = paragraph - } - - unowned let paragraph: CanvasParagraph - - // *** Results of layout *** // - - // Look at the Paragraph class for documentation of the following properties. - - var width: Float = -1.0 - - var height: Float = 0.0 - - var longestLine: ParagraphLine? - - var minIntrinsicWidth: Float = 0.0 - - var maxIntrinsicWidth: Float = 0.0 - - var alphabeticBaseline: Float = -1.0 - - var ideographicBaseline: Float = -1.0 - - var didExceedMaxLines: Bool = false - - var lines: [ParagraphLine] = [] - - /// The bounds that contain the text painted inside this paragraph. - var paintBounds: Rect { - _paintBounds - } - private var _paintBounds: Rect = Rect.zero - - lazy var spanometer: Spanometer = Spanometer(paragraph: paragraph) - - lazy var layoutFragmenter: LayoutFragmenter = LayoutFragmenter( - paragraph.plainText, - paragraph.spans - ) - - /// Performs the layout on a paragraph given the [constraints]. - /// - /// The function starts by resetting all layout-related properties. Then it - /// starts looping through the paragraph to calculate all layout metrics. - /// - /// It uses a [Spanometer] to perform measurements within spans of the - /// paragraph. It also uses [LineBuilders] to generate [ParagraphLine]s as - /// it iterates through the paragraph. - /// - /// The main loop keeps going until: - /// - /// 1. The end of the paragraph is reached (i.e. LineBreakType.endOfText). - /// 2. Enough lines have been computed to satisfy [maxLines]. - /// 3. An ellipsis is appended because of an overflow. - func performLayout(_ constraints: ParagraphConstraints) { - // Reset results from previous layout. - // width = constraints.width - width = - switch constraints { - case .width(let width): width - } - height = 0.0 - longestLine = nil - minIntrinsicWidth = 0.0 - maxIntrinsicWidth = 0.0 - didExceedMaxLines = false - lines.removeAll() - - let constraintsWidth = - switch constraints { - case .width(let width): width - } - var currentLine = LineBuilder.first( - paragraph: paragraph, - spanometer: spanometer, - maxWidth: constraintsWidth - ) - - let fragments = layoutFragmenter.fragment() - for fragment in fragments { - spanometer.measureFragment(fragment) - } - - outerLoop: for var i in 0.. maxLines { - didExceedMaxLines = true - lines.removeSubrange(maxLines.. boundsRight { - boundsRight = right - } - } - _paintBounds = Rect( - left: boundsLeft, - top: 0, - right: boundsRight, - bottom: height - ) - - // **************************** // - // *** FRAGMENT POSITIONING *** // - // **************************** // - - // We have to perform justification alignment first so that we can position - // fragments correctly later. - if !lines.isEmpty { - let shouldJustifyParagraph = - width.isFinite && paragraph.paragraphStyle.textAlign == TextAlign.justify - - if shouldJustifyParagraph { - // Don't apply justification to the last line. - for i in 0..<(lines.count - 1) { - for fragment in lines[i].fragments { - fragment.justifyTo(paragraphWidth: width) - } - } - } - } - - lines.forEach(_positionLineFragments) - - // ******************************** // - // *** MAX/MIN INTRINSIC WIDTHS *** // - // ******************************** // - - // TODO(mdebbar): Handle maxLines https://github.com/flutter/flutter/issues/91254 - - var runningMinIntrinsicWidth: Float = 0 - var runningMaxIntrinsicWidth: Float = 0 - - for fragment in fragments { - runningMinIntrinsicWidth += fragment.widthExcludingTrailingSpaces - // Max intrinsic width includes the width of trailing spaces. - runningMaxIntrinsicWidth += fragment.widthIncludingTrailingSpaces - - switch fragment.type { - case .prohibited: - break - - case .opportunity: - minIntrinsicWidth = max(minIntrinsicWidth, runningMinIntrinsicWidth) - runningMinIntrinsicWidth = 0 - - case .mandatory, .endOfText: - minIntrinsicWidth = max(minIntrinsicWidth, runningMinIntrinsicWidth) - maxIntrinsicWidth = max(maxIntrinsicWidth, runningMaxIntrinsicWidth) - runningMinIntrinsicWidth = 0 - runningMaxIntrinsicWidth = 0 - } - } - } - - private var _paragraphDirection: TextDirection { - paragraph.paragraphStyle.effectiveTextDirection - } - - /// Positions the fragments taking into account their directions and the - /// paragraph's direction. - private func _positionLineFragments(_ line: ParagraphLine) { - var previousDirection = _paragraphDirection - - var startOffset: Float = 0.0 - var sandwichStart: Int? - var sequenceStart = 0 - - for i in 0...line.fragments.count { - if i < line.fragments.count { - let fragment = line.fragments[i] - - if fragment.fragmentFlow == .previous { - sandwichStart = nil - continue - } - if fragment.fragmentFlow == .sandwich { - sandwichStart = sandwichStart ?? i - continue - } - - assert(fragment.fragmentFlow == .ltr || fragment.fragmentFlow == .rtl) - - let currentDirection = - fragment.fragmentFlow == .ltr ? TextDirection.ltr : TextDirection.rtl - - if currentDirection == previousDirection { - sandwichStart = nil - continue - } - } - - // We've reached a fragment that'll flip the text direction. Let's - // position the sequence that we've been traversing. - - if sandwichStart == nil { - // Position fragments in range [sequenceStart:i) - startOffset += _positionFragmentRange( - line: line, - start: sequenceStart, - end: i, - direction: previousDirection, - startOffset: startOffset - ) - } else { - // Position fragments in range [sequenceStart:sandwichStart) - startOffset += _positionFragmentRange( - line: line, - start: sequenceStart, - end: sandwichStart!, - direction: previousDirection, - startOffset: startOffset - ) - // Position fragments in range [sandwichStart:i) - startOffset += _positionFragmentRange( - line: line, - start: sandwichStart!, - end: i, - direction: _paragraphDirection, - startOffset: startOffset - ) - } - - sequenceStart = i - sandwichStart = nil - - if i < line.fragments.count { - previousDirection = line.fragments[i].textDirection! - } - } - } - - private func _positionFragmentRange( - line: ParagraphLine, - start: Int, - end: Int, - direction: TextDirection, - startOffset: Float - ) -> Float { - assert(start <= end) - - var cumulativeWidth: Float = 0.0 - - // The bodies of the two for loops below must remain identical. The only - // difference is the looping direction. One goes from start to end, while - // the other goes from end to start. - - if direction == _paragraphDirection { - for i in start.. Float { - let fragment = line.fragments[i] - fragment.setPosition(startOffset: startOffset, textDirection: direction) - return fragment.widthIncludingTrailingSpaces - } - - func getBoxesForPlaceholders() -> [TextBox] { - var boxes: [TextBox] = [] - for line in lines { - for fragment in line.fragments { - if fragment.isPlaceholder { - boxes.append(fragment.toTextBox()) - } - } - } - return boxes - } - - func getBoxesForRange( - _ start: TextIndex, - _ end: TextIndex, - _ boxHeightStyle: BoxHeightStyle, - _ boxWidthStyle: BoxWidthStyle - ) -> [TextBox] { - // Zero-length ranges and invalid ranges return an empty list. - if start >= end || start < .zero || end < .zero { - return [] - } - - let length = TextIndex(utf16Offset: paragraph.plainText.utf16.count) - // Ranges that are out of bounds should return an empty list. - if start > length || end > length { - return [] - } - - var boxes: [TextBox] = [] - - for line in lines { - if line.overlapsWith(start, end) { - for fragment in line.fragments { - if !fragment.isPlaceholder && fragment.overlapsWith(start: start, end: end) { - boxes.append(fragment.toTextBox(start: start, end: end)) - } - } - } - } - return boxes - } - - func getPositionForOffset(_ offset: Offset) -> TextPosition { - // After layout, each line has boxes that contain enough information to make - // it possible to do hit testing. Once we find the box, we look inside that - // box to find where exactly the `offset` is located. - - guard let line = _findLineForY(offset.dy) else { - return TextPosition(offset: .zero) - } - // [offset] is to the left of the line. - if offset.dx <= line.left { - return TextPosition( - offset: line.startIndex - ) - } - - // [offset] is to the right of the line. - if offset.dx >= line.left + line.widthWithTrailingSpaces { - return TextPosition( - offset: line.endIndex - line.trailingNewlines, - affinity: TextAffinity.upstream - ) - } - - let dx = offset.dx - line.left - for fragment in line.fragments { - if fragment.left <= dx && dx <= fragment.right { - return fragment.getPositionForX(dx - fragment.left) - } - } - // Is this ever reachable? - return TextPosition(offset: line.startIndex) - } - - func getClosestGlyphInfo(_ offset: Offset) -> GlyphInfo? { - guard let line = _findLineForY(offset.dy) else { - return nil - } - guard let fragment = line.closestFragmentAtOffset(offset.dx - line.left) else { - return nil - } - let dx = offset.dx - let closestGraphemeStartInFragment = - !fragment.hasLeadingBrokenGrapheme - || dx <= fragment.line.left - || fragment.line.left + fragment.line.width <= dx - || { - switch fragment.textDirection! { - // If dx is closer to the trailing edge, no need to check other fragments. - case .ltr: - return dx >= line.left + (fragment.left + fragment.right) / 2 - case .rtl: - return dx <= line.left + (fragment.left + fragment.right) / 2 - } - }() - let candidate1 = fragment.getClosestCharacterBox(dx) - if closestGraphemeStartInFragment { - return candidate1 - } - let searchLeft = fragment.textDirection! == .ltr - guard - let candidate2 = fragment.line.closestFragmentTo(fragment, searchLeft: searchLeft)? - .getClosestCharacterBox(dx) - else { - return candidate1 - } - - let distance1 = min( - abs(candidate1.graphemeClusterLayoutBounds.left - dx), - abs(candidate1.graphemeClusterLayoutBounds.right - dx) - ) - let distance2 = min( - abs(candidate2.graphemeClusterLayoutBounds.left - dx), - abs(candidate2.graphemeClusterLayoutBounds.right - dx) - ) - return distance2 > distance1 ? candidate1 : candidate2 - } - - private func _findLineForY(_ y: Float) -> ParagraphLine? { - if lines.isEmpty { - return nil - } - // We could do a binary search here but it's not worth it because the number - // of line is typically low, and each iteration is a cheap comparison of - // doubles. - var remainingY = y - for line in lines { - if remainingY <= line.height { - return line - } - remainingY -= line.height - } - return lines.last - } -} - -/// Builds instances of [ParagraphLine] for the given [paragraph]. -/// -/// Usage of this class starts by calling [LineBuilder.first] to start building -/// the first line of the paragraph. -/// -/// Then fragments can be added by calling [addFragment]. -/// -/// After adding a fragment, one can use [isOverflowing] to determine whether -/// the added fragment caused the line to overflow or not. -/// -/// Once the line is complete, it can be built by calling [build] to generate -/// a [ParagraphLine] instance. -/// -/// To start building the next line, simply call [nextLine] to get a new -/// [LineBuilder] for the next line. -class LineBuilder { - private let paragraph: CanvasParagraph - private let spanometer: Spanometer - private let maxWidth: Float - private let lineNumber: Int - private let accumulatedHeight: Float - private var fragments: [LayoutFragment] - private var fragmentsForNextLine: [LayoutFragment]? - - private init( - paragraph: CanvasParagraph, - spanometer: Spanometer, - maxWidth: Float, - lineNumber: Int, - accumulatedHeight: Float, - fragments: [LayoutFragment] - ) { - self.paragraph = paragraph - self.spanometer = spanometer - self.maxWidth = maxWidth - self.lineNumber = lineNumber - self.accumulatedHeight = accumulatedHeight - self.fragments = fragments - recalculateMetrics() - } - - /// Creates a [LineBuilder] for the first line in a paragraph. - static func first( - paragraph: CanvasParagraph, - spanometer: Spanometer, - maxWidth: Float - ) -> LineBuilder { - return LineBuilder( - paragraph: paragraph, - spanometer: spanometer, - maxWidth: maxWidth, - lineNumber: 0, - accumulatedHeight: 0.0, - fragments: [] - ) - } - - var startIndex: TextIndex { - assert(!fragments.isEmpty || !fragmentsForNextLine!.isEmpty) - - return !isEmpty - ? fragments.first!.start - : fragmentsForNextLine!.first!.start - } - - var endIndex: TextIndex { - assert(!fragments.isEmpty || !fragmentsForNextLine!.isEmpty) - - return !isEmpty - ? fragments.last!.end - : fragmentsForNextLine!.first!.start - } - - /// The width of the line so far, excluding trailing white space. - private(set) var width: Float = 0.0 - - /// The width of the line so far, including trailing white space. - private(set) var widthIncludingSpace: Float = 0.0 - - private var widthExcludingLastFragment: Float { - return fragments.count > 1 - ? widthIncludingSpace - fragments.last!.widthIncludingTrailingSpaces - : 0 - } - - /// The distance from the top of the line to the alphabetic baseline. - private(set) var ascent: Float = 0.0 - - /// The distance from the bottom of the line to the alphabetic baseline. - private(set) var descent: Float = 0.0 - - /// The height of the line so far. - var height: Float { ascent + descent } - - private var lastBreakableFragment = -1 - private var breakCount = 0 - - /// Whether this line can be legally broken into more than one line. - var isBreakable: Bool { - if fragments.isEmpty { - return false - } - if fragments.last!.isBreak { - // We need one more break other than the last one. - return breakCount > 1 - } - return breakCount > 0 - } - - /// Returns true if the line can't be legally broken any further. - var isNotBreakable: Bool { !isBreakable } - - private var spaceCount = TextIndex.zero - private var trailingSpaces = TextIndex.zero - - var isEmpty: Bool { fragments.isEmpty } - var isNotEmpty: Bool { !fragments.isEmpty } - - var isHardBreak: Bool { !fragments.isEmpty && fragments.last!.isHardBreak } - - /// The horizontal offset necessary for the line to be correctly aligned. - var alignOffset: Float { - let emptySpace = maxWidth - width - let textAlign = paragraph.paragraphStyle.effectiveTextAlign - - switch textAlign { - case .center: - return emptySpace / 2.0 - case .right: - return emptySpace - case .start: - return paragraphDirection == .rtl ? emptySpace : 0.0 - case .end: - return paragraphDirection == .rtl ? 0.0 : emptySpace - default: - return 0.0 - } - } - - var isOverflowing: Bool { width > maxWidth } - - var canHaveEllipsis: Bool { - if paragraph.paragraphStyle.ellipsis == nil { - return false - } - - let maxLines = paragraph.paragraphStyle.maxLines - return maxLines == nil || maxLines == lineNumber + 1 - } - - private var canAppendEmptyFragments: Bool { - if isHardBreak { - // Can't append more fragments to this line if it has a hard break. - return false - } - - if let fragmentsForNextLine = fragmentsForNextLine, !fragmentsForNextLine.isEmpty { - // If we already have fragments prepared for the next line, then we can't - // append more fragments to this line. - return false - } - - return true - } - - private var paragraphDirection: TextDirection { - paragraph.paragraphStyle.effectiveTextDirection - } - - func addFragment(_ fragment: LayoutFragment) { - updateMetrics(fragment) - - if fragment.isBreak { - lastBreakableFragment = fragments.count - } - - fragments.append(fragment) - } - - /// Updates the [LineBuilder]'s metrics to take into account the new [fragment]. - private func updateMetrics(_ fragment: LayoutFragment) { - spaceCount = spaceCount + fragment.trailingSpaces - - if fragment.isSpaceOnly { - trailingSpaces = trailingSpaces + fragment.trailingSpaces - } else { - trailingSpaces = fragment.trailingSpaces - width = widthIncludingSpace + fragment.widthExcludingTrailingSpaces - } - widthIncludingSpace += fragment.widthIncludingTrailingSpaces - - if fragment.isPlaceholder { - adjustPlaceholderAscentDescent(fragment) - } - - if fragment.isBreak { - breakCount += 1 - } - - ascent = max(ascent, fragment.ascent) - descent = max(descent, fragment.descent) - } - - private func adjustPlaceholderAscentDescent(_ fragment: LayoutFragment) { - let placeholder = fragment.span as! PlaceholderSpan - - let (ascent, descent): (Float, Float) - switch placeholder.placeholder.alignment { - case .top: - // The placeholder is aligned to the top of text, which means it has the - // same `ascent` as the remaining text. We only need to extend the - // `descent` enough to fit the placeholder. - ascent = self.ascent - descent = placeholder.placeholder.height - self.ascent - - case .bottom: - // The opposite of `top`. The `descent` is the same, but we extend the - // `ascent`. - ascent = placeholder.placeholder.height - self.descent - descent = self.descent - - case .middle: - let textMidPoint = height / 2 - let placeholderMidPoint = placeholder.placeholder.height / 2 - let diff = placeholderMidPoint - textMidPoint - ascent = self.ascent + diff - descent = self.descent + diff - - case .aboveBaseline: - ascent = placeholder.placeholder.height - descent = 0.0 - - case .belowBaseline: - ascent = 0.0 - descent = placeholder.placeholder.height - - case .baseline: - ascent = placeholder.placeholder.baselineOffset - descent = placeholder.placeholder.height - ascent - } - - // Update the metrics of the fragment to reflect the calculated ascent and - // descent. - fragment.setMetrics( - spanometer, - ascent: ascent, - descent: descent, - widthExcludingTrailingSpaces: fragment.widthExcludingTrailingSpaces, - widthIncludingTrailingSpaces: fragment.widthIncludingTrailingSpaces - ) - } - - private func recalculateMetrics() { - width = 0 - widthIncludingSpace = 0 - ascent = 0 - descent = 0 - spaceCount = TextIndex.zero - trailingSpaces = TextIndex.zero - breakCount = 0 - lastBreakableFragment = -1 - - for (i, fragment) in fragments.enumerated() { - updateMetrics(fragment) - if fragment.isBreak { - lastBreakableFragment = i - } - } - } - - func forceBreakLastFragment(availableWidth: Float? = nil, allowEmptyLine: Bool = false) { - assert(isNotEmpty) - - let availableWidth = availableWidth ?? maxWidth - assert(widthIncludingSpace > availableWidth) - - fragmentsForNextLine = fragmentsForNextLine ?? [] - - // When the line has fragments other than the last one, we can always allow - // the last fragment to be empty (i.e. completely removed from the line). - let hasOtherFragments = fragments.count > 1 - let allowLastFragmentToBeEmpty = hasOtherFragments || allowEmptyLine - - let lastFragment = fragments.last! - - if lastFragment.isPlaceholder { - // Placeholder can't be force-broken. Either keep all of it in the line or - // move it to the next line. - if allowLastFragmentToBeEmpty { - fragmentsForNextLine!.insert(fragments.removeLast(), at: 0) - recalculateMetrics() - } - return - } - - spanometer.currentSpan = lastFragment.span - let lineWidthWithoutLastFragment = - widthIncludingSpace - lastFragment.widthIncludingTrailingSpaces - let availableWidthForFragment = availableWidth - lineWidthWithoutLastFragment - let forceBreakEnd = lastFragment.end - lastFragment.trailingNewlines - - let breakingPoint = spanometer.forceBreak( - start: lastFragment.start, - end: forceBreakEnd, - availableWidth: availableWidthForFragment, - allowEmpty: allowLastFragmentToBeEmpty - ) - - if breakingPoint == forceBreakEnd { - // The entire fragment remained intact. Let's keep everything as is. - return - } - - fragments.removeLast() - recalculateMetrics() - - let split = lastFragment.split(breakingPoint) - - if let first = split.first ?? nil { - spanometer.measureFragment(first) - addFragment(first) - } - - if let second = split.last ?? nil { - spanometer.measureFragment(second) - fragmentsForNextLine!.insert(second, at: 0) - } - } - - func insertEllipsis() { - assert(canHaveEllipsis) - assert(isOverflowing) - - let ellipsisText = paragraph.paragraphStyle.ellipsis! - - fragmentsForNextLine = [] - - spanometer.currentSpan = fragments.last!.span - var ellipsisWidth = spanometer.measureText(ellipsisText) - var availableWidth = max(0, maxWidth - ellipsisWidth) - - while widthExcludingLastFragment > availableWidth { - fragmentsForNextLine!.insert(fragments.removeLast(), at: 0) - recalculateMetrics() - - spanometer.currentSpan = fragments.last!.span - ellipsisWidth = spanometer.measureText(ellipsisText) - availableWidth = maxWidth - ellipsisWidth - } - - let lastFragment = fragments.last! - forceBreakLastFragment(availableWidth: availableWidth, allowEmptyLine: true) - - let ellipsisFragment = EllipsisFragment( - index: endIndex, - span: lastFragment.span - ) - ellipsisFragment.setMetrics( - spanometer, - ascent: lastFragment.ascent, - descent: lastFragment.descent, - widthExcludingTrailingSpaces: ellipsisWidth, - widthIncludingTrailingSpaces: ellipsisWidth - ) - addFragment(ellipsisFragment) - } - - func revertToLastBreakOpportunity() { - assert(isBreakable) - - // The last fragment in the line may or may not be breakable. Regardless, - // it needs to be removed. - // - // We need to find the latest breakable fragment in the line (other than the - // last fragment). Such breakable fragment is guaranteed to be found because - // the line `isBreakable`. - - // Start from the end and skip the last fragment. - var i = fragments.count - 2 - while !fragments[i].isBreak { - i -= 1 - } - - fragmentsForNextLine = Array(fragments[(i + 1)...]) - fragments.removeSubrange((i + 1)...) - recalculateMetrics() - } - - /// Appends as many zero-width fragments as this line allows. - /// - /// Returns the number of fragments that were appended. - func appendZeroWidthFragments(_ fragments: [LayoutFragment], startFrom: Int) -> Int { - var i = startFrom - while canAppendEmptyFragments && i < fragments.count - && fragments[i].widthExcludingTrailingSpaces == 0 - { - addFragment(fragments[i]) - i += 1 - } - return i - startFrom - } - - /// Builds the [ParagraphLine] instance that represents this line. - func build() -> ParagraphLine { - if fragmentsForNextLine == nil { - fragmentsForNextLine = Array(fragments[(lastBreakableFragment + 1)...]) - fragments.removeSubrange((lastBreakableFragment + 1)...) - } - let trailingNewlines = isEmpty ? .zero : fragments.last!.trailingNewlines - let line = ParagraphLine( - hardBreak: isHardBreak, - ascent: ascent, - descent: descent, - height: height, - width: width, - left: alignOffset, - baseline: accumulatedHeight + ascent, - lineNumber: lineNumber, - startIndex: startIndex, - endIndex: endIndex, - trailingNewlines: trailingNewlines, - trailingSpaces: trailingSpaces, - spaceCount: spaceCount, - widthWithTrailingSpaces: widthIncludingSpace, - fragments: fragments, - textDirection: paragraphDirection, - paragraph: paragraph - ) - - for fragment in fragments { - fragment.line = line - } - - return line - } - - /// Creates a new [LineBuilder] to build the next line in the paragraph. - func nextLine() -> LineBuilder { - return LineBuilder( - paragraph: paragraph, - spanometer: spanometer, - maxWidth: maxWidth, - lineNumber: lineNumber + 1, - accumulatedHeight: accumulatedHeight + height, - fragments: fragmentsForNextLine ?? [] - ) - } -} - -/// Responsible for taking measurements within spans of a paragraph. -/// -/// Can't perform measurements across spans. To measure across spans, multiple -/// measurements have to be taken. -/// -/// Before performing any measurement, the [currentSpan] has to be set. Once -/// it's set, the [Spanometer] updates the underlying [context] so that -/// subsequent measurements use the correct styles. -class Spanometer { - init(paragraph: CanvasParagraph) { - self.paragraph = paragraph - } - - let paragraph: CanvasParagraph - - private static let _rulerHost = RulerHost() - - private static var _rulers: [TextHeightStyle: TextHeightRuler] = [:] - - static var rulers: [TextHeightStyle: TextHeightRuler] { _rulers } - - /// Clears the cache of rulers that are used for measuring text height and - /// baseline metrics. - static func clearRulersCache() { - for (_, ruler) in _rulers { - ruler.dispose() - } - _rulers.removeAll() - } - - var letterSpacing: Float? { - currentSpan!.style.letterSpacing - } - - private var _currentRuler: TextHeightRuler? - var currentSpan: ParagraphSpanProtocol? { - didSet { - // Update the font string if it's different from the last applied font - // string. - // - // Also, we need to update the font string even if the span isn't changing. - // That's because `textContext` is shared across all spanometers. - if let span = currentSpan { - let newCssFontString = span.style.cssFontString - if _lastContextFont != newCssFontString { - _lastContextFont = newCssFontString - textContext.font = .string(newCssFontString) - } - - // Update the height ruler. - // If the ruler doesn't exist in the cache, create a new one and cache it. - let heightStyle = span.style.heightStyle - var ruler = Self._rulers[heightStyle] - if ruler == nil { - ruler = TextHeightRuler(heightStyle, Self._rulerHost) - Self._rulers[heightStyle] = ruler - } - _currentRuler = ruler - } else { - _currentRuler = nil - } - } - } - - /// Whether the spanometer is ready to take measurements. - var isReady: Bool { currentSpan != nil } - - /// The distance from the top of the current span to the alphabetic baseline. - var ascent: Float { _currentRuler!.alphabeticBaseline } - - /// The distance from the bottom of the current span to the alphabetic baseline. - var descent: Float { height - ascent } - - /// The line height of the current span. - var height: Float { _currentRuler!.height } - - func measureText(_ text: String) -> Float { - return measureSubstring( - textContext, - text, - TextIndex(utf16Offset: 0), - TextIndex(utf16Offset: text.utf16.count) - ) - } - - func measureRange(start: TextIndex, end: TextIndex) -> Float { - assert(currentSpan != nil) - - // Make sure the range is within the current span. - assert(start >= currentSpan!.start && start <= currentSpan!.end) - assert(end >= currentSpan!.start && end <= currentSpan!.end) - - return _measure(start: start, end: end) - } - - func measureFragment(_ fragment: LayoutFragment) { - if fragment.isPlaceholder { - let placeholder = fragment.span as! PlaceholderSpan - // The ascent/descent values of the placeholder fragment will be finalized - // later when the line is built. - fragment.setMetrics( - self, - ascent: placeholder.placeholder.height, - descent: 0, - widthExcludingTrailingSpaces: placeholder.placeholder.width, - widthIncludingTrailingSpaces: placeholder.placeholder.width - ) - } else { - currentSpan = fragment.span - let widthExcludingTrailingSpaces = _measure( - start: fragment.start, - end: fragment.end - fragment.trailingSpaces - ) - let widthIncludingTrailingSpaces = _measure( - start: fragment.start, - end: fragment.end - fragment.trailingNewlines - ) - fragment.setMetrics( - self, - ascent: ascent, - descent: descent, - widthExcludingTrailingSpaces: widthExcludingTrailingSpaces, - widthIncludingTrailingSpaces: widthIncludingTrailingSpaces - ) - } - } - - /// In a continuous, unbreakable block of text from [start] to [end], finds - /// the point where text should be broken to fit in the given [availableWidth]. - /// - /// The [start] and [end] indices have to be within the same text span. - /// - /// When [allowEmpty] is true, the result is guaranteed to be at least one - /// character after [start]. But if [allowEmpty] is false and there isn't - /// enough [availableWidth] to fit the first character, then [start] is - /// returned. - /// - /// See also: - /// - [LineBuilder.forceBreak]. - func forceBreak( - start: TextIndex, - end: TextIndex, - availableWidth: Float, - allowEmpty: Bool - ) -> TextIndex { - assert(currentSpan != nil) - - // Make sure the range is within the current span. - assert(start >= currentSpan!.start && start <= currentSpan!.end) - assert(end >= currentSpan!.start && end <= currentSpan!.end) - - if availableWidth <= 0.0 { - return allowEmpty ? start : start + .one - } - - var low = start - var high = end - while high.utf16Offset - low.utf16Offset > 1 { - let mid = TextIndex(utf16Offset: (low.utf16Offset + high.utf16Offset) / 2) - let width = _measure(start: start, end: mid) - if width < availableWidth { - low = mid - } else if width > availableWidth { - high = mid - } else { - low = mid - high = mid - } - } - - if low == start && !allowEmpty { - low = low + .one - } - return low - } - - private func _measure(start: TextIndex, end: TextIndex) -> Float { - assert(currentSpan != nil) - // Make sure the range is within the current span. - assert(start >= currentSpan!.start && start <= currentSpan!.end) - assert(end >= currentSpan!.start && end <= currentSpan!.end) - - return measureSubstring( - textContext, - paragraph.plainText, - start, - end, - letterSpacing: letterSpacing - ) - } -} diff --git a/Sources/ShaftWeb/Text/LineBreakProperties.swift b/Sources/ShaftWeb/Text/LineBreakProperties.swift deleted file mode 100644 index ad7ede7..0000000 --- a/Sources/ShaftWeb/Text/LineBreakProperties.swift +++ /dev/null @@ -1,61 +0,0 @@ -/// For an explanation of these enum values, see: -/// -/// * https://www.unicode.org/reports/tr14/tr14-45.html#DescriptionOfProperties -enum LineCharProperty: CaseIterable { - case CM // Combining Mark - serialized as "A" - case BA // Break After - serialized as "B" - case LF // Line Feed - serialized as "C" - // Normalized from: NL (Next Line) - case BK // Break Mandatory - serialized as "D" - case CR // Carriage Return - serialized as "E" - case SP // Space - serialized as "F" - case EX // Exclamation - serialized as "G" - case QU // Quotation - serialized as "H" - // Normalized from: AI (Ambiguous), SA (Complex Context), SG (Surrogate), XX (Unknown) - case AL // Alphabetic - serialized as "I" - case PR // Prefix Numeric - serialized as "J" - case PO // Postfix Numeric - serialized as "K" - case OP // Open Punctuation - serialized as "L" - case CP // Close Punctuation - serialized as "M" - case IS // Infix Numeric Separator - serialized as "N" - case HY // Hyphen - serialized as "O" - case SY // Symbol - serialized as "P" - case NU // Numeric - serialized as "Q" - case CL // Close Punctuation - serialized as "R" - case GL // Glue - serialized as "S" - case BB // Break Before - serialized as "T" - case HL // Hebrew Letter - serialized as "U" - case JL // Hangul L Jamo - serialized as "V" - case JV // Hangul V Jamo - serialized as "W" - case JT // Hangul T Jamo - serialized as "X" - // Normalized from: CJ (Conditional Japanese Starter) - case NS // Nonstarter - serialized as "Y" - case ZW // Zero Width Space - serialized as "Z" - case ZWJ // Zero Width Joiner - serialized as "a" - case B2 // Break Opportunity Before and After - serialized as "b" - case IN // Inseparable - serialized as "c" - case WJ // Word Joiner - serialized as "d" - case ID // Ideographic - serialized as "e" - case EB // Emoji Base - serialized as "f" - case H2 // Hangul LV Syllable - serialized as "g" - case H3 // Hangul LVT Syllable - serialized as "h" - case CB // Contingent Break Opportunity - serialized as "i" - case RI // Regional Indicator - serialized as "j" - case EM // Emoji Modifier - serialized as "k" -} - -let packedLineBreakProperties = - "00000008A0009!B000a!C000b000cD000d!E000e000vA000w!F000x!G000y!H000z!I0010!J0011!K0012!I0013!H0014!L0015!M0016!I0017!J0018!N0019!O001a!N001b!P001c001lQ001m001nN001o001qI001r!G001s002iI002j!L002k!J002l!M002m003eI003f!L003g!B003h!R003i!I003j003oA003p!D003q004fA004g!S004h!L004i!K004j004lJ004m004qI004r!H004s!I004t!B004u004vI004w!K004x!J004y004zI0050!T00510056I0057!H0058005aI005b!L005c00jrI00js!T00jt00jvI00jw!T00jx00keI00kf!T00kg00lbI00lc00niA00nj!S00nk00nvA00nw00o2S00o300ofA00og00otI00ou!N00ov00w2I00w300w9A00wa013cI013d!N013e!B013h013iI013j!J013l014tA014u!B014v!A014w!I014x014yA014z!I01500151A0152!G0153!A015c0162U0167016aU016b016wI016x016zK01700171N01720173I0174017eA017f!G017g!A017i017jG017k018qI018r019bA019c019lQ019m!K019n019oQ019p019rI019s!A019t01cjI01ck!G01cl!I01cm01csA01ct01cuI01cv01d0A01d101d2I01d301d4A01d5!I01d601d9A01da01dbI01dc01dlQ01dm01e8I01e9!A01ea01f3I01f401fuA01fx01idI01ie01ioA01ip!I01j401jdQ01je01kaI01kb01kjA01kk01knI01ko!N01kp!G01kq!I01kt!A01ku01kvJ01kw01lhI01li01llA01lm!I01ln01lvA01lw!I01lx01lzA01m0!I01m101m5A01m801ncI01nd01nfA01ni01qfI01qr01r5A01r6!I01r701s3A01s401tlI01tm01toA01tp!I01tq01u7A01u8!I01u901ufA01ug01upI01uq01urA01us01utB01uu01v3Q01v401vkI01vl01vnA01vp01x5I01x8!A01x9!I01xa01xgA01xj01xkA01xn01xpA01xq!I01xz!A01y401y9I01ya01ybA01ye01ynQ01yo01ypI01yq01yrK01ys01ywI01yx!K01yy!I01yz!J01z001z1I01z2!A01z501z7A01z9020pI020s!A020u020yA02130214A02170219A021d!A021l021qI021y0227Q02280229A022a022cI022d!A022e!I022p022rA022t0249I024c!A024d!I024e024lA024n024pA024r024tA024w025dI025e025fA025i025rQ025s!I025t!J0261!I02620267A0269026bA026d027tI027w!A027x!I027y0284A02870288A028b028dA028l028nA028s028xI028y028zA0292029bQ029c029jI029u!A029v02bdI02bi02bmA02bq02bsA02bu02bxA02c0!I02c7!A02cm02cvQ02cw02d4I02d5!J02d6!I02dc02dgA02dh02f1I02f202f8A02fa02fcA02fe02fhA02fp02fqA02fs02g1I02g202g3A02g602gfQ02gn!T02go02gwI02gx02gzA02h0!T02h102ihI02ik!A02il!I02im02isA02iu02iwA02iy02j1A02j902jaA02ji02jlI02jm02jnA02jq02jzQ02k102k2I02kg02kjA02kk02m2I02m302m4A02m5!I02m602mcA02me02mgA02mi02mlA02mm02muI02mv!A02mw02n5I02n602n7A02na02njQ02nk02nsI02nt!K02nu02nzI02o102o3A02o502pyI02q2!A02q702qcA02qe!A02qg02qnA02qu02r3Q02r602r7A02r802t6I02tb!J02tc02trI02ts02u1Q02u202u3B02v502x9I02xc02xlQ02xo02yoI02yp02ysT02yt!I02yu02yvT02yw!S02yx02yyT02yz!B02z0!S02z102z5G02z6!S02z7!I02z8!G02z902zbI02zc02zdA02ze02zjI02zk02ztQ02zu0303I0304!B0305!A0306!I0307!A0308!I0309!A030a!L030b!R030c!L030d!R030e030fA030g031oI031t0326A0327!B0328032cA032d!B032e032fA032g032kI032l032vA032x033wA033y033zB03400345I0346!A0347034fI034g034hT034i!B034j!T034k034oI034p034qS035s037jI037k037tQ037u037vB037w039rI039s03a1Q03a203cvI03cw03fjV03fk03hjW03hk03jzX03k003tmI03tp03trA03ts!I03tt!B03tu03y5I03y8!B03y904fzI04g0!B04g104gqI04gr!L04gs!R04gw04iyI04iz04j1B04j204k1I04k204k4A04kg04kxI04ky04l0A04l104l2B04lc04ltI04lu04lvA04m804moI04mq04mrA04n404pfI04pg04phB04pi!Y04pj!I04pk!B04pl!I04pm!B04pn!J04po04ppI04ps04q1Q04q804qpI04qq04qrG04qs04qtB04qu!T04qv!I04qw04qxG04qy!I04qz04r1A04r2!S04r404rdQ04rk04ucI04ud04ueA04uf04vcI04vd!A04ve04ymI04yo04yzA04z404zfA04zk!I04zo04zpG04zq04zzQ0500053dI053k053tQ053u055iI055j055nA055q058cI058f!A058g058pQ058w0595Q059c059pI059s05a8A05c005c4A05c505dfI05dg05dwA05dx05e3I05e805ehQ05ei05ejB05ek!I05el05eoB05ep05eyI05ez05f7A05f805fgI05fk05fmA05fn05ggI05gh05gtA05gu05gvI05gw05h5Q05h605idI05ie05irA05j005k3I05k405knA05kr05kvB05kw05l5Q05l905lbI05lc05llQ05lm05mlI05mm05mnB05mo05onI05ow05oyA05oz!I05p005pkA05pl05poI05pp!A05pq05pvI05pw!A05px05pyI05pz05q1A05q205vjI05vk05x5A05x705xbA05xc06bgI06bh!T06bi!I06bk06bqB06br!S06bs06buB06bv!Z06bw!A06bx!a06by06bzA06c0!B06c1!S06c206c3B06c4!b06c506c7I06c806c9H06ca!L06cb06cdH06ce!L06cf!H06cg06cjI06ck06cmc06cn!B06co06cpD06cq06cuA06cv!S06cw06d3K06d4!I06d506d6H06d7!I06d806d9Y06da06dfI06dg!N06dh!L06di!R06dj06dlY06dm06dxI06dy!B06dz!I06e006e3B06e4!I06e506e7B06e8!d06e906ecI06ee06enA06eo06f0I06f1!L06f2!R06f306fgI06fh!L06fi!R06fk06fwI06g006g6J06g7!K06g806glJ06gm!K06gn06gqJ06gr!K06gs06gtJ06gu!K06gv06hbJ06hc06i8A06io06iqI06ir!K06is06iwI06ix!K06iy06j9I06ja!J06jb06q9I06qa06qbJ06qc06weI06wf!c06wg06x3I06x4!L06x5!R06x6!L06x7!R06x806xlI06xm06xne06xo06y0I06y1!L06y2!R06y3073jI073k073ne073o07i7I07i807ibe07ic07irI07is07ite07iu07ivI07iw!e07ix!I07iy07j0e07j1!f07j207j3e07j407jsI07jt07jve07jw07l3I07l4!e07l507lqI07lr!e07ls07ngI07nh07nse07nt07nwI07nx!e07ny!I07nz07o1e07o2!I07o307o4e07o507o7I07o807o9e07oa07obI07oc!e07od07oeI07of07ohe07oi07opI07oq!e07or07owI07ox07p1e07p2!I07p307p4e07p5!f07p6!e07p707p8I07p907pge07ph07pjI07pk07ple07pm07ppf07pq07ruI07rv07s0H07s1!I07s207s3G07s4!e07s507s7I07s8!L07s9!R07sa!L07sb!R07sc!L07sd!R07se!L07sf!R07sg!L07sh!R07si!L07sj!R07sk!L07sl!R07sm07usI07ut!L07uu!R07uv07vpI07vq!L07vr!R07vs!L07vt!R07vu!L07vv!R07vw!L07vx!R07vy!L07vz!R07w00876I0877!L0878!R0879!L087a!R087b!L087c!R087d!L087e!R087f!L087g!R087h!L087i!R087j!L087k!R087l!L087m!R087n!L087o!R087p!L087q!R087r!L087s!R087t089jI089k!L089l!R089m!L089n!R089o08ajI08ak!L08al!R08am08viI08vj08vlA08vm08vnI08vt!G08vu08vwB08vx!I08vy!G08vz!B08w008z3I08z4!B08zj!A08zk0926I09280933A0934093hH093i093pB093q!I093r!B093s!L093t!B093u093vI093w093xH093y093zI09400941H0942!L0943!R0944!L0945!R0946!L0947!R0948!L0949!R094a094dB094e!G094f!I094g094hB094i!I094j094kB094l094pI094q094rb094s094uB094v!I094w094xB094y!L094z0956B0957!I0958!B0959!I095a095bB095c095eI096o097de097f099ve09a809g5e09gw09h7e09hc!B09hd09heR09hf09hge09hh!Y09hi09hje09hk!L09hl!R09hm!L09hn!R09ho!L09hp!R09hq!L09hr!R09hs!L09ht!R09hu09hve09hw!L09hx!R09hy!L09hz!R09i0!L09i1!R09i2!L09i3!R09i4!Y09i5!L09i609i7R09i809ihe09ii09inA09io09ise09it!A09iu09iye09iz09j0Y09j109j3e09j5!Y09j6!e09j7!Y09j8!e09j9!Y09ja!e09jb!Y09jc!e09jd!Y09je09k2e09k3!Y09k409kye09kz!Y09l0!e09l1!Y09l2!e09l3!Y09l409l9e09la!Y09lb09lge09lh09liY09ll09lmA09ln09lqY09lr!e09ls09ltY09lu!e09lv!Y09lw!e09lx!Y09ly!e09lz!Y09m0!e09m1!Y09m209mqe09mr!Y09ms09nme09nn!Y09no!e09np!Y09nq!e09nr!Y09ns09nxe09ny!Y09nz09o4e09o509o6Y09o709oae09ob09oeY09of!e09ol09pre09pt09see09sg09ure09v409vjY09vk09wee09wg09xje09xk09xrI09xs0fcve0fcw0fenI0feo0vmce0vmd!Y0vme0wi4e0wi80wjqe0wk00wl9I0wla0wlbB0wlc0wssI0wst!B0wsu!G0wsv!B0wsw0wtbI0wtc0wtlQ0wtm0wviI0wvj0wvmA0wvn!I0wvo0wvxA0wvy0wwtI0wwu0wwvA0www0wz3I0wz40wz5A0wz6!I0wz70wzbB0wzk0x6pI0x6q!A0x6r0x6tI0x6u!A0x6v0x6yI0x6z!A0x700x7mI0x7n0x7rA0x7s0x7vI0x7w!A0x800x87I0x88!K0x890x9vI0x9w0x9xT0x9y0x9zG0xa80xa9A0xaa0xbnI0xbo0xc5A0xce0xcfB0xcg0xcpQ0xcw0xddA0xde0xdnI0xdo!T0xdp0xdqI0xdr!A0xds0xe1Q0xe20xetI0xeu0xf1A0xf20xf3B0xf40xfqI0xfr0xg3A0xgf!I0xgg0xh8V0xhc0xhfA0xhg0xiqI0xir0xj4A0xj50xjaI0xjb0xjdB0xje0xjjI0xjk0xjtQ0xjy0xkfI0xkg0xkpQ0xkq0xm0I0xm10xmeA0xmo0xmqI0xmr!A0xms0xmzI0xn00xn1A0xn40xndQ0xng!I0xnh0xnjB0xnk0xreI0xrf0xrjA0xrk0xrlB0xrm0xroI0xrp0xrqA0xs10xyaI0xyb0xyiA0xyj!B0xyk0xylA0xyo0xyxQ0xz4!g0xz50xzvh0xzw!g0xzx0y0nh0y0o!g0y0p0y1fh0y1g!g0y1h0y27h0y28!g0y290y2zh0y30!g0y310y3rh0y3s!g0y3t0y4jh0y4k!g0y4l0y5bh0y5c!g0y5d0y63h0y64!g0y650y6vh0y6w!g0y6x0y7nh0y7o!g0y7p0y8fh0y8g!g0y8h0y97h0y98!g0y990y9zh0ya0!g0ya10yarh0yas!g0yat0ybjh0ybk!g0ybl0ycbh0ycc!g0ycd0yd3h0yd4!g0yd50ydvh0ydw!g0ydx0yenh0yeo!g0yep0yffh0yfg!g0yfh0yg7h0yg8!g0yg90ygzh0yh0!g0yh10yhrh0yhs!g0yht0yijh0yik!g0yil0yjbh0yjc!g0yjd0yk3h0yk4!g0yk50ykvh0ykw!g0ykx0ylnh0ylo!g0ylp0ymfh0ymg!g0ymh0yn7h0yn8!g0yn90ynzh0yo0!g0yo10yorh0yos!g0yot0ypjh0ypk!g0ypl0yqbh0yqc!g0yqd0yr3h0yr4!g0yr50yrvh0yrw!g0yrx0ysnh0yso!g0ysp0ytfh0ytg!g0yth0yu7h0yu8!g0yu90yuzh0yv0!g0yv10yvrh0yvs!g0yvt0ywjh0ywk!g0ywl0yxbh0yxc!g0yxd0yy3h0yy4!g0yy50yyvh0yyw!g0yyx0yznh0yzo!g0yzp0z0fh0z0g!g0z0h0z17h0z18!g0z190z1zh0z20!g0z210z2rh0z2s!g0z2t0z3jh0z3k!g0z3l0z4bh0z4c!g0z4d0z53h0z54!g0z550z5vh0z5w!g0z5x0z6nh0z6o!g0z6p0z7fh0z7g!g0z7h0z87h0z88!g0z890z8zh0z90!g0z910z9rh0z9s!g0z9t0zajh0zak!g0zal0zbbh0zbc!g0zbd0zc3h0zc4!g0zc50zcvh0zcw!g0zcx0zdnh0zdo!g0zdp0zefh0zeg!g0zeh0zf7h0zf8!g0zf90zfzh0zg0!g0zg10zgrh0zgs!g0zgt0zhjh0zhk!g0zhl0zibh0zic!g0zid0zj3h0zj4!g0zj50zjvh0zjw!g0zjx0zknh0zko!g0zkp0zlfh0zlg!g0zlh0zm7h0zm8!g0zm90zmzh0zn0!g0zn10znrh0zns!g0znt0zojh0zok!g0zol0zpbh0zpc!g0zpd0zq3h0zq4!g0zq50zqvh0zqw!g0zqx0zrnh0zro!g0zrp0zsfh0zsg!g0zsh0zt7h0zt8!g0zt90ztzh0zu0!g0zu10zurh0zus!g0zut0zvjh0zvk!g0zvl0zwbh0zwc!g0zwd0zx3h0zx4!g0zx50zxvh0zxw!g0zxx0zynh0zyo!g0zyp0zzfh0zzg!g0zzh1007h1008!g1009100zh1010!g1011101rh101s!g101t102jh102k!g102l103bh103c!g103d1043h1044!g1045104vh104w!g104x105nh105o!g105p106fh106g!g106h1077h1078!g1079107zh1080!g1081108rh108s!g108t109jh109k!g109l10abh10ac!g10ad10b3h10b4!g10b510bvh10bw!g10bx10cnh10co!g10cp10dfh10dg!g10dh10e7h10e8!g10e910ezh10f0!g10f110frh10fs!g10ft10gjh10gk!g10gl10hbh10hc!g10hd10i3h10i4!g10i510ivh10iw!g10ix10jnh10jo!g10jp10kfh10kg!g10kh10l7h10l8!g10l910lzh10m0!g10m110mrh10ms!g10mt10njh10nk!g10nl10obh10oc!g10od10p3h10p4!g10p510pvh10pw!g10px10qnh10qo!g10qp10rfh10rg!g10rh10s7h10s8!g10s910szh10t0!g10t110trh10ts!g10tt10ujh10uk!g10ul10vbh10vc!g10vd10w3h10w4!g10w510wvh10ww!g10wx10xnh10xo!g10xp10yfh10yg!g10yh10z7h10z8!g10z910zzh1100!g1101110rh110s!g110t111jh111k!g111l112bh112c!g112d1133h1134!g1135113vh113w!g113x114nh114o!g114p115fh115g!g115h1167h1168!g1169116zh1170!g1171117rh117s!g117t118jh118k!g118l119bh119c!g119d11a3h11a4!g11a511avh11aw!g11ax11bnh11bo!g11bp11cfh11cg!g11ch11d7h11d8!g11d911dzh11e0!g11e111erh11es!g11et11fjh11fk!g11fl11gbh11gc!g11gd11h3h11h4!g11h511hvh11hw!g11hx11inh11io!g11ip11jfh11jg!g11jh11k7h11k8!g11k911kzh11l0!g11l111lrh11ls!g11lt11mjh11mk!g11ml11nbh11nc!g11nd11o3h11o4!g11o511ovh11ow!g11ox11pnh11po!g11pp11qfh11qg!g11qh11r7h11r8!g11r911rzh11s0!g11s111srh11ss!g11st11tjh11tk!g11tl11ubh11uc!g11ud11v3h11v4!g11v511vvh11vw!g11vx11wnh11wo!g11wp11xfh11xg!g11xh11y7h11y8!g11y911yzh11z0!g11z111zrh11zs!g11zt120jh120k!g120l121bh121c!g121d1223h1224!g1225122vh122w!g122x123nh123o!g123p124fh124g!g124h1257h1258!g1259125zh1260!g1261126rh126s!g126t127jh127k!g127l128bh128c!g128d1293h1294!g1295129vh129w!g129x12anh12ao!g12ap12bfh12bg!g12bh12c7h12c8!g12c912czh12d0!g12d112drh12ds!g12dt12ejh12ek!g12el12fbh12fc!g12fd12g3h12g4!g12g512gvh12gw!g12gx12hnh12ho!g12hp12ifh12ig!g12ih12j7h12j8!g12j912jzh12k0!g12k112krh12ks!g12kt12ljh12lk!g12ll12mbh12mc!g12md12n3h12n4!g12n512nvh12nw!g12nx12onh12oo!g12op12pfh12pg!g12ph12q7h12q8!g12q912qzh12r0!g12r112rrh12rs!g12rt12sjh12sk!g12sl12tbh12tc!g12td12u3h12u4!g12u512uvh12uw!g12ux12vnh12vo!g12vp12wfh12wg!g12wh12x7h12x8!g12x912xzh12y0!g12y112yrh12ys!g12yt12zjh12zk!g12zl130bh130c!g130d1313h1314!g1315131vh131w!g131x132nh132o!g132p133fh133g!g133h1347h1348!g1349134zh1350!g1351135rh135s!g135t136jh136k!g136l137bh137c!g137d1383h1384!g1385138vh138w!g138x139nh139o!g139p13afh13ag!g13ah13b7h13b8!g13b913bzh13c0!g13c113crh13cs!g13ct13djh13dk!g13dl13ebh13ec!g13ed13f3h13f4!g13f513fvh13fw!g13fx13gnh13go!g13gp13hfh13hg!g13hh13i7h13i8!g13i913izh13j0!g13j113jrh13js!g13jt13kjh13kk!g13kl13lbh13lc!g13ld13m3h13m4!g13m513mvh13mw!g13mx13nnh13no!g13np13ofh13og!g13oh13p7h13p8!g13p913pzh13q0!g13q113qrh13qs!g13qt13rjh13rk!g13rl13sbh13sc!g13sd13t3h13t4!g13t513tvh13tw!g13tx13unh13uo!g13up13vfh13vg!g13vh13w7h13w8!g13w913wzh13x0!g13x113xrh13xs!g13xt13yjh13yk!g13yl13zbh13zc!g13zd1403h1404!g1405140vh140w!g140x141nh141o!g141p142fh142g!g142h1437h1438!g1439143zh1440!g1441144rh144s!g144t145jh145k!g145l146bh146c!g146d1473h1474!g1475147vh147w!g147x148nh148o!g148p149fh149g!g149h14a7h14a8!g14a914azh14b0!g14b114brh14bs!g14bt14cjh14ck!g14cl14dbh14dc!g14dd14e3h14e4!g14e514evh14ew!g14ex14fnh14fo!g14fp14gfh14gg!g14gh14h7h14h8!g14h914hzh14i0!g14i114irh14is!g14it14jjh14jk!g14jl14kbh14kc!g14kd14l3h14l4!g14l514lvh14lw!g14lx14mnh14mo!g14mp14nfh14ng!g14nh14o7h14o8!g14o914ozh14p0!g14p114prh14ps!g14pt14qjh14qk!g14ql14rbh14rc!g14rd14s3h14s4!g14s514svh14sw!g14sx14tnh14to!g14tp14ufh14ug!g14uh14v7h14v8!g14v914vzh14w0!g14w114wrh14ws!g14wt14xjh14xk!g14xl14ybh14yc!g14yd14z3h14z4!g14z514zvh14zw!g14zx150nh150o!g150p151fh151g!g151h1527h1528!g1529152zh1530!g1531153rh153s!g153t154jh154k!g154l155bh155c!g155d1563h1564!g1565156vh156w!g156x157nh157o!g157p158fh158g!g158h1597h1598!g1599159zh15a0!g15a115arh15as!g15at15bjh15bk!g15bl15cbh15cc!g15cd15d3h15d4!g15d515dvh15dw!g15dx15enh15eo!g15ep15ffh15fg!g15fh15g7h15g8!g15g915gzh15h0!g15h115hrh15hs!g15ht15ijh15ik!g15il15jbh15jc!g15jd15k3h15k4!g15k515kvh15kw!g15kx15lnh15lo!g15lp15mfh15mg!g15mh15n7h15n8!g15n915nzh15o0!g15o115orh15os!g15ot15pjh15pk!g15pl15qbh15qc!g15qd15r3h15r4!g15r515rvh15rw!g15rx15snh15so!g15sp15tfh15tg!g15th15u7h15u8!g15u915uzh15v0!g15v115vrh15vs!g15vt15wjh15wk!g15wl15xbh15xc!g15xd15y3h15y4!g15y515yvh15yw!g15yx15znh15zo!g15zp160fh160g!g160h1617h1618!g1619161zh1620!g1621162rh162s!g162t163jh163k!g163l164bh164c!g164d1653h1654!g1655165vh165w!g165x166nh166o!g166p167fh167g!g167h1687h1688!g1689168zh1690!g1691169rh169s!g169t16ajh16ak!g16al16bbh16bc!g16bd16c3h16c4!g16c516cvh16cw!g16cx16dnh16do!g16dp16efh16eg!g16eh16f7h16f8!g16f916fzh16g0!g16g116grh16gs!g16gt16hjh16hk!g16hl16ibh16ic!g16id16j3h16j4!g16j516jvh16jw!g16jx16knh16ko!g16kp16lfh16ls16meW16mj16nvX16o01d6nI1d6o1dkve1dkw1dljI1dlp!U1dlq!A1dlr1dm0U1dm1!I1dm21dmeU1dmg1dmkU1dmm!U1dmo1dmpU1dmr1dmsU1dmu1dn3U1dn41e0tI1e0u!R1e0v!L1e1c1e63I1e64!K1e65!I1e681e6nA1e6o!N1e6p1e6qR1e6r1e6sN1e6t1e6uG1e6v!L1e6w!R1e6x!c1e741e7jA1e7k1e7oe1e7p!L1e7q!R1e7r!L1e7s!R1e7t!L1e7u!R1e7v!L1e7w!R1e7x!L1e7y!R1e7z!L1e80!R1e81!L1e82!R1e83!L1e84!R1e851e86e1e87!L1e88!R1e891e8fe1e8g!R1e8h!e1e8i!R1e8k1e8lY1e8m1e8nG1e8o!e1e8p!L1e8q!R1e8r!L1e8s!R1e8t!L1e8u!R1e8v1e92e1e94!e1e95!J1e96!K1e97!e1e9c1ed8I1edb!d1edd!G1ede1edfe1edg!J1edh!K1edi1edje1edk!L1edl!R1edm1edne1edo!R1edp!e1edq!R1edr1ee1e1ee21ee3Y1ee41ee6e1ee7!G1ee81eeye1eez!L1ef0!e1ef1!R1ef21efue1efv!L1efw!e1efx!R1efy!e1efz!L1eg01eg1R1eg2!L1eg31eg4R1eg5!Y1eg6!e1eg71eggY1egh1ehpe1ehq1ehrY1ehs1eime1eiq1eive1eiy1ej3e1ej61ejbe1eje1ejge1ejk!K1ejl!J1ejm1ejoe1ejp1ejqJ1ejs1ejyI1ek91ekbA1ekc!i1ekd1ereI1erk1ermB1err1eykI1eyl!A1f281f4gI1f4w!A1f4x1f91I1f921f96A1f9c1fa5I1fa7!B1fa81fbjI1fbk!B1fbl1fh9I1fhc1fhlQ1fhs1g7pI1g7r!B1g7s1gd7I1gdb!B1gdc1gjkI1gjl1gjnA1gjp1gjqA1gjw1gjzA1gk01gl1I1gl41gl6A1glb!A1glc1glkI1gls1glzB1gm01gpwI1gpx1gpyA1gq31gq7I1gq81gqdB1gqe!c1gqo1gs5I1gs91gsfB1gsg1h5vI1h5w1h5zA1h681h6hQ1heo1hgpI1hgr1hgsA1hgt!B1hgw1hl1I1hl21hlcA1hld1hpyI1hq81hqaA1hqb1hrrI1hrs1hs6A1hs71hs8B1hs91ht1I1ht21htbQ1htr1htuA1htv1hv3I1hv41hveA1hvf1hvhI1hvi1hvlB1hvx1hwoI1hww1hx5Q1hxc1hxeA1hxf1hyeI1hyf1hysA1hyu1hz3Q1hz41hz7B1hz8!I1hz91hzaA1hzb1i0iI1i0j!A1i0k!I1i0l!T1i0m!I1i0w1i0yA1i0z1i2aI1i2b1i2oA1i2p1i2sI1i2t1i2uB1i2v!I1i2w!B1i2x1i30A1i31!I1i321i33A1i341i3dQ1i3e!I1i3f!T1i3g!I1i3h1i3jB1i3l1i5nI1i5o1i5zA1i601i61B1i62!I1i631i64B1i65!I1i66!A1i801i94I1i95!B1i9c1iamI1ian1iayA1ib41ibdQ1ibk1ibnA1ibp1id5I1id71id8A1id9!I1ida1idgA1idj1idkA1idn1idpA1ids!I1idz!A1ie51ie9I1iea1iebA1iee1iekA1ieo1iesA1iio1ik4I1ik51ikmA1ikn1ikqI1ikr1ikuB1ikv!I1ikw1il5Q1il61il7B1il9!I1ila!A1ilb1injI1ink1io3A1io41io7I1iog1iopQ1itc1iumI1iun1iutA1iuw1iv4A1iv5!T1iv61iv7B1iv81iv9G1iva1ivcI1ivd1ivrB1ivs1ivvI1ivw1ivxA1iww1iy7I1iy81iyoA1iyp1iyqB1iyr1iysI1iz41izdQ1izk1izwT1j0g1j1mI1j1n1j1zA1j20!I1j281j2hQ1j401j57I1j5c1j5lQ1j5m1j5nI1j5o1j5qB1j5r1jcbI1jcc1jcqA1jcr1jhbI1jhc1jhlQ1jhm1jjjI1jjk1jjpA1jjr1jjsA1jjv1jjyA1jjz!I1jk0!A1jk1!I1jk21jk3A1jk41jk6B1jkg1jkpQ1jmo1jo0I1jo11jo7A1joa1jogA1joh!I1joi!T1joj!I1jok!A1jpc!I1jpd1jpmA1jpn1jqqI1jqr1jqxA1jqy!I1jqz1jr2A1jr3!T1jr4!I1jr51jr8B1jr9!T1jra!I1jrb!A1jrk!I1jrl1jrvA1jrw1jt5I1jt61jtlA1jtm1jtoB1jtp!I1jtq1jtsT1jtt1jtuB1juo1k4uI1k4v1k52A1k541k5bA1k5c!I1k5d1k5hB1k5s1k61Q1k621k6kI1k6o!T1k6p!G1k6q1k7jI1k7m1k87A1k891k8mA1kao1kc0I1kc11kc6A1kca!A1kcc1kcdA1kcf1kclA1kcm!I1kcn!A1kcw1kd5Q1kdc1kehI1kei1kemA1keo1kepA1ker1kevA1kew!I1kf41kfdQ1ko01koiI1koj1komA1kon1kv0I1kv11kv4K1kv51kvlI1kvz!B1kw01lriI1lrk1lroB1ls01oifI1oig1oiiL1oij1oilR1oim1ojlI1ojm!R1ojn1ojpI1ojq!L1ojr!R1ojs!L1ojt!R1oju1oqgI1oqh!L1oqi1oqjR1oqk1oviI1ovk1ovqS1ovr!L1ovs!R1s001sctI1scu!L1scv!R1scw1zkuI1zkw1zl5Q1zla1zlbB1zo01zotI1zow1zp0A1zp1!B1zpc1zqnI1zqo1zquA1zqv1zqxB1zqy1zr7I1zr8!B1zr9!I1zrk1zrtQ1zrv20euI20ev20ewB20ex20juI20jz!A20k0!I20k120ljA20lr20luA20lv20m7I20o020o3Y20o4!S20og20ohA20ow25fbe25fk260ve260w26dxI26f426fce2dc02djye2dlc2dleY2dlw2dlzY2dm82dx7e2fpc2ftoI2ftp2ftqA2ftr!B2fts2ftvA2jnk2jxgI2jxh2jxlA2jxm2jxoI2jxp2jyaA2jyb2jycI2jyd2jyjA2jyk2jzdI2jze2jzhA2jzi2k3lI2k3m2k3oA2k3p2l6zI2l722l8fQ2l8g2lmnI2lmo2lo6A2lo72loaI2lob2lpoA2lpp2lpwI2lpx!A2lpy2lqbI2lqc!A2lqd2lqeI2lqf2lqiB2lqj!I2lqz2lr3A2lr52lrjA2mtc2mtiA2mtk2mu0A2mu32mu9A2mub2mucA2mue2muiA2n0g2n1oI2n1s2n1yA2n1z2n25I2n282n2hQ2n2m2ne3I2ne42ne7A2ne82nehQ2nen!J2oe82ojzI2ok02ok6A2olc2on7I2on82oneA2onf!I2onk2ontQ2ony2onzL2p9t2pbfI2pbg!K2pbh2pbjI2pbk!K2pbl2prlI2pz42q67e2q682q6kI2q6l2q6ne2q6o2q98I2q992q9be2q9c2qb0I2qb12qcle2qcm2qdbj2qdc2qo4e2qo5!f2qo62qore2qos2qotI2qou2qpge2qph2qpiI2qpj2qpne2qpo!I2qpp2qpte2qpu2qpwf2qpx2qpye2qpz!f2qq02qq1e2qq22qq4f2qq52qree2qrf2qrjk2qrk2qtde2qte2qtff2qtg2qthe2qti2qtsf2qtt2qude2que2quwf2qux2quze2qv0!f2qv12qv4e2qv52qv7f2qv8!e2qv92qvbf2qvc2qvie2qvj!f2qvk!e2qvl!f2qvm2qvze2qw0!I2qw1!e2qw2!I2qw3!e2qw4!I2qw52qw9e2qwa!f2qwb2qwee2qwf!I2qwg!e2qwh2qwiI2qwj2qyne2qyo2qyuI2qyv2qzae2qzb2qzoI2qzp2r01e2r022r0pI2r0q2r1ve2r1w2r1xf2r1y2r21e2r22!f2r232r2ne2r2o!f2r2p2r2se2r2t2r2uf2r2v2r4je2r4k2r4rI2r4s2r5fe2r5g2r5lI2r5m2r7oe2r7p2r7rf2r7s2r7ue2r7v2r7zf2r802r91I2r922r94H2r952r97Y2r982r9bI2r9c2raae2rab!f2rac2rare2ras2rauf2rav2rb3e2rb4!f2rb52rbfe2rbg!f2rbh2rcve2rcw2rg3I2rg42rgfe2rgg2risI2rit2rjze2rk02rkbI2rkc2rkfe2rkg2rlzI2rm02rm7e2rm82rmhI2rmi2rmne2rmo2rnrI2rns2rnze2ro02rotI2rou2rr3e2rr42rrfI2rrg!f2rrh2rrie2rrj!f2rrk2rrre2rrs2rrzf2rs02rs5e2rs6!f2rs72rsfe2rsg2rspf2rsq2rsre2rss2rsuf2rsv2ruee2ruf!f2rug2rw4e2rw52rw6f2rw7!e2rw82rw9f2rwa!e2rwb!f2rwc2rwse2rwt2rwvf2rww!e2rwx2rx9f2rxa2ry7e2ry82s0jI2s0k2s5be2s5c2sayI2sc02sc9Q2scg2t4te2t4w47p9e47pc5m9pejny9!Ajnz4jo1rAjo5cjobzAl2ionvnhI" - -let singleLineBreakRangesCount = 937 - -let defaultLineCharProperty = LineCharProperty.AL - -var lineLookup: UnicodePropertyLookup = { - return UnicodePropertyLookup.fromPackedData( - packedLineBreakProperties, - singleLineBreakRangesCount, - LineCharProperty.allCases, - defaultLineCharProperty - ) -}() diff --git a/Sources/ShaftWeb/Text/LineBreaker.swift b/Sources/ShaftWeb/Text/LineBreaker.swift deleted file mode 100644 index df3176d..0000000 --- a/Sources/ShaftWeb/Text/LineBreaker.swift +++ /dev/null @@ -1,756 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Shaft - -let _kNewlines: Set = [ - 0x000A, // LF - 0x000B, // BK - 0x000C, // BK - 0x000D, // CR - 0x0085, // NL - 0x2028, // BK - 0x2029, // BK -] - -let _kSpaces: Set = [ - 0x0020, // SP - 0x200B, // ZW -] - -/// Various types of line breaks as defined by the Unicode spec. -enum LineBreakType { - /// Indicates that a line break is possible but not mandatory. - case opportunity - - /// Indicates that a line break isn't possible. - case prohibited - - /// Indicates that this is a hard line break that can't be skipped. - case mandatory - - /// Indicates the end of the text (which is also considered a line break in - /// the Unicode spec). This is the same as [mandatory] but it's needed in our - /// implementation to distinguish between the universal [endOfText] and the - /// line break caused by "\n" at the end of the text. - case endOfText -} - -/// Splits [text] into fragments based on line breaks. -protocol LineBreakFragmenter: TextFragmenter { - func fragment() -> [LineBreakFragment] -} - -/// Factory method to create the appropriate LineBreakFragmenter -func createLineBreakFragmenter(text: String) -> any LineBreakFragmenter { - // if domIntl.v8BreakIterator != nil { - // return V8LineBreakFragmenter(text: text) - // } - return FWLineBreakFragmenter(text: text) -} - -/// Flutter web's custom implementation of [LineBreakFragmenter]. -class FWLineBreakFragmenter: LineBreakFragmenter { - typealias FragmentType = LineBreakFragment - - let text: String - - init(text: String) { - self.text = text - } - - func fragment() -> [LineBreakFragment] { - return _computeLineBreakFragments(text) - } -} - -// /// An implementation of [LineBreakFragmenter] that uses V8's -// /// `v8BreakIterator` API to find line breaks in the given [text]. -// class V8LineBreakFragmenter: TextFragmenter, LineBreakFragmenter { -// init(text: String) { -// super.init(text: text) -// assert(domIntl.v8BreakIterator != nil) -// } - -// private let _v8BreakIterator = createV8BreakIterator() - -// func fragment() -> [LineBreakFragment] { -// return breakLinesUsingV8BreakIterator( -// text: text, -// jsText: text.toJS, -// iterator: _v8BreakIterator -// ) -// } -// } - -// func breakLinesUsingV8BreakIterator(text: String, jsText: JSString, iterator: DomV8BreakIterator) -// -> [LineBreakFragment] -// { -// var breaks: [LineBreakFragment] = [] -// var fragmentStart = 0 - -// iterator.adoptText(jsText) -// iterator.first() -// while iterator.next() != -1 { -// let fragmentEnd = iterator.current().toInt() -// var trailingNewlines = 0 -// var trailingSpaces = 0 - -// // Calculate trailing newlines and spaces. -// for i in fragmentStart.. 0 { -// breaks.append( -// LineBreakFragment( -// fragmentStart, -// i, -// .opportunity, -// trailingNewlines: trailingNewlines, -// trailingSpaces: trailingSpaces -// ) -// ) -// fragmentStart = i -// trailingNewlines = 0 -// trailingSpaces = 0 -// } -// } -// } - -// let type: LineBreakType -// if trailingNewlines > 0 { -// type = .mandatory -// } else if fragmentEnd == text.count { -// type = .endOfText -// } else { -// type = .opportunity -// } - -// breaks.append( -// LineBreakFragment( -// fragmentStart, -// fragmentEnd, -// type, -// trailingNewlines: trailingNewlines, -// trailingSpaces: trailingSpaces -// ) -// ) -// fragmentStart = fragmentEnd -// } - -// if breaks.isEmpty || breaks.last?.type == .mandatory { -// breaks.append( -// LineBreakFragment( -// text.count, -// text.count, -// .endOfText, -// trailingNewlines: 0, -// trailingSpaces: 0 -// ) -// ) -// } - -// return breaks -// } - -struct LineBreakFragment: TextFragment { - let start: TextIndex - let end: TextIndex - let type: LineBreakType - let trailingNewlines: TextIndex - let trailingSpaces: TextIndex - - init( - _ start: TextIndex, - _ end: TextIndex, - _ type: LineBreakType, - trailingNewlines: TextIndex, - trailingSpaces: TextIndex - ) { - self.start = start - self.end = end - self.type = type - self.trailingNewlines = trailingNewlines - self.trailingSpaces = trailingSpaces - } -} - -func _isHardBreak(_ prop: LineCharProperty?) -> Bool { - // No need to check for NL because it's already normalized to BK. - return prop == .LF || prop == .BK -} - -func _isALorHL(_ prop: LineCharProperty?) -> Bool { - return prop == .AL || prop == .HL -} - -/// Whether the given property is part of a Korean Syllable block. -/// -/// See: -/// - https://unicode.org/reports/tr14/tr14-45.html#LB27 -func _isKoreanSyllable(_ prop: LineCharProperty?) -> Bool { - return prop == .JL || prop == .JV || prop == .JT || prop == .H2 || prop == .H3 -} - -/// Whether the given char code has an Eastern Asian width property of F, W or H. -/// -/// See: -/// - https://www.unicode.org/reports/tr14/tr14-45.html#LB30 -/// - https://www.unicode.org/Public/13.0.0/ucd/EastAsianWidth.txt -func _hasEastAsianWidthFWH(_ charCode: Int) -> Bool { - return charCode == 0x2329 || (charCode >= 0x3008 && charCode <= 0x301D) - || (charCode >= 0xFE17 && charCode <= 0xFF62) -} - -func _isSurrogatePair(_ codePoint: Int?) -> Bool { - return codePoint != nil && codePoint! > 0xFFFF -} - -/// Finds the next line break in the given text starting from the specified index. -/// -/// We think about indices as pointing between characters, and they go all the -/// way from 0 to the string length. For example, here are the indices for the -/// string "foo bar": -/// -/// ```none -/// f o o b a r -/// ^ ^ ^ ^ ^ ^ ^ ^ -/// 0 1 2 3 4 5 6 7 -/// ``` -/// -/// This way the indices work well with string substring operations. -/// -/// Useful resources: -/// -/// * https://www.unicode.org/reports/tr14/tr14-45.html#Algorithm -/// * https://www.unicode.org/Public/11.0.0/ucd/LineBreak.txt -func _computeLineBreakFragments(_ text: String) -> [LineBreakFragment] { - var fragments: [LineBreakFragment] = [] - - // Keeps track of the character two positions behind. - var prev2: LineCharProperty? - var prev1: LineCharProperty? - - var codePoint: Int? = Int(text.unicodeScalars.first?.value ?? 0) - var curr = lineLookup.findForChar(codePoint) - - // When there's a sequence of combining marks, this variable contains the base - // property i.e. the property of the character preceding the sequence. - var baseOfCombiningMarks: LineCharProperty = .AL - - var index = TextIndex.zero - var trailingNewlines = TextIndex.zero - var trailingSpaces = TextIndex.zero - - var fragmentStart = TextIndex.zero - - func setBreak(_ type: LineBreakType, _ debugRuleNumber: Int) { - let fragmentEnd = type == .endOfText ? TextIndex(utf16Offset: text.utf16.count) : index - assert(fragmentEnd >= fragmentStart) - - // Uncomment the following line to help debug line breaking. - // print("\(fragmentStart):\(fragmentEnd) [\(debugRuleNumber)] -- \(type)") - - if prev1 == .SP { - trailingSpaces = trailingSpaces.advanced(by: 1) - } else if _isHardBreak(prev1) || prev1 == .CR { - trailingNewlines = trailingNewlines.advanced(by: 1) - trailingSpaces = trailingSpaces.advanced(by: 1) - } - - if type == .prohibited { - // Don't create a fragment. - return - } - - fragments.append( - LineBreakFragment( - fragmentStart, - fragmentEnd, - type, - trailingNewlines: trailingNewlines, - trailingSpaces: trailingSpaces - ) - ) - - fragmentStart = index - - // Reset trailing spaces/newlines counter after a new fragment. - trailingNewlines = .zero - trailingSpaces = .zero - - prev1 = nil - prev2 = nil - } - - // Never break at the start of text. - // LB2: sot × - setBreak(.prohibited, 2) - - // Never break at the start of text. - // LB2: sot × - // - // Skip index 0 because a line break can't exist at the start of text. - index = index.advanced(by: 1) - - var regionalIndicatorCount = 0 - - // We need to go until `text.count` in order to handle the case where the - // paragraph ends with a hard break. In this case, there will be an empty line - // at the end. - while index <= TextIndex(utf16Offset: text.utf16.count) { - prev2 = prev1 - prev1 = curr - - if _isSurrogatePair(codePoint) { - // Can't break in the middle of a surrogate pair. - setBreak(.prohibited, -1) - // Advance `index` one extra step to skip the tail of the surrogate pair. - index = index.advanced(by: 1) - } - - codePoint = getCodePoint(text, index) - curr = lineLookup.findForChar(codePoint) - - // Keep count of the RI (regional indicator) sequence. - if prev1 == .RI { - regionalIndicatorCount += 1 - } else { - regionalIndicatorCount = 0 - } - - // Always break after hard line breaks. - // LB4: BK ! - // - // Treat CR followed by LF, as well as CR, LF, and NL as hard line breaks. - // LB5: LF ! - // NL ! - if _isHardBreak(prev1) { - setBreak(.mandatory, 5) - index = index.advanced(by: 1) - continue - } - - if prev1 == .CR { - if curr == .LF { - // LB5: CR × LF - setBreak(.prohibited, 5) - } else { - // LB5: CR ! - setBreak(.mandatory, 5) - } - index = index.advanced(by: 1) - continue - } - - // Do not break before hard line breaks. - // LB6: × ( BK | CR | LF | NL ) - if _isHardBreak(curr) || curr == .CR { - setBreak(.prohibited, 6) - index = index.advanced(by: 1) - continue - } - - if index >= TextIndex(utf16Offset: text.utf16.count) { - break - } - - // Do not break before spaces or zero width space. - // LB7: × SP - // × ZW - if curr == .SP || curr == .ZW { - setBreak(.prohibited, 7) - index = index.advanced(by: 1) - continue - } - - // Break after spaces. - // LB18: SP ÷ - if prev1 == .SP { - setBreak(.opportunity, 18) - index = index.advanced(by: 1) - continue - } - - // Break before any character following a zero-width space, even if one or - // more spaces intervene. - // LB8: ZW SP* ÷ - if prev1 == .ZW { - setBreak(.opportunity, 8) - index = index.advanced(by: 1) - continue - } - - // Do not break after a zero width joiner. - // LB8a: ZWJ × - if prev1 == .ZWJ { - setBreak(.prohibited, 8) - index = index.advanced(by: 1) - continue - } - - // Establish the base for the sequences of combining marks. - if prev1 != .CM && prev1 != .ZWJ { - baseOfCombiningMarks = prev1 ?? .AL - } - - // Do not break a combining character sequence; treat it as if it has the - // line breaking class of the base character in all of the following rules. - // Treat ZWJ as if it were CM. - if curr == .CM || curr == .ZWJ { - if baseOfCombiningMarks == .SP { - // LB10: Treat any remaining combining mark or ZWJ as AL. - curr = .AL - } else { - // LB9: Treat X (CM | ZWJ)* as if it were X - // where X is any line break class except BK, NL, LF, CR, SP, or ZW. - curr = baseOfCombiningMarks - if curr == .RI { - // Prevent the previous RI from being double-counted. - regionalIndicatorCount -= 1 - } - setBreak(.prohibited, 9) - index = index.advanced(by: 1) - continue - } - } - // In certain situations (e.g. CM immediately following a hard break), we - // need to also check if the previous character was CM/ZWJ. That's because - // hard breaks caused the previous iteration to short-circuit, which leads - // to `baseOfCombiningMarks` not being updated properly. - if prev1 == .CM || prev1 == .ZWJ { - prev1 = baseOfCombiningMarks - } - - // Do not break before or after Word joiner and related characters. - // LB11: × WJ - // WJ × - if curr == .WJ || prev1 == .WJ { - setBreak(.prohibited, 11) - index = index.advanced(by: 1) - continue - } - - // Do not break after NBSP and related characters. - // LB12: GL × - if prev1 == .GL { - setBreak(.prohibited, 12) - index = index.advanced(by: 1) - continue - } - - // Do not break before NBSP and related characters, except after spaces and - // hyphens. - // LB12a: [^SP BA HY] × GL - if !(prev1 == .SP || prev1 == .BA || prev1 == .HY) && curr == .GL { - setBreak(.prohibited, 12) - index = index.advanced(by: 1) - continue - } - - // Do not break before ']' or '!' or ';' or '/', even after spaces. - // LB13: × CL - // × CP - // × EX - // × IS - // × SY - // - // The above is a quote from unicode.org. In our implementation, we did the - // following modification: When there are spaces present, we consider it a - // line break opportunity. - // - // We made this modification to match the browser behavior. - if prev1 != .SP && (curr == .CL || curr == .CP || curr == .EX || curr == .IS || curr == .SY) - { - setBreak(.prohibited, 13) - index = index.advanced(by: 1) - continue - } - - // Do not break after '[', even after spaces. - // LB14: OP SP* × - // - // The above is a quote from unicode.org. In our implementation, we did the - // following modification: Allow breaks when there are spaces. - // - // We made this modification to match the browser behavior. - if prev1 == .OP { - setBreak(.prohibited, 14) - index = index.advanced(by: 1) - continue - } - - // Do not break within '"[', even with intervening spaces. - // LB15: QU SP* × OP - // - // The above is a quote from unicode.org. In our implementation, we did the - // following modification: Allow breaks when there are spaces. - // - // We made this modification to match the browser behavior. - if prev1 == .QU && curr == .OP { - setBreak(.prohibited, 15) - index = index.advanced(by: 1) - continue - } - - // Do not break between closing punctuation and a nonstarter, even with - // intervening spaces. - // LB16: (CL | CP) SP* × NS - // - // The above is a quote from unicode.org. In our implementation, we did the - // following modification: Allow breaks when there are spaces. - // - // We made this modification to match the browser behavior. - if (prev1 == .CL || prev1 == .CP) && curr == .NS { - setBreak(.prohibited, 16) - index = index.advanced(by: 1) - continue - } - - // Do not break within '——', even with intervening spaces. - // LB17: B2 SP* × B2 - // - // The above is a quote from unicode.org. In our implementation, we did the - // following modification: Allow breaks when there are spaces. - // - // We made this modification to match the browser behavior. - if prev1 == .B2 && curr == .B2 { - setBreak(.prohibited, 17) - index = index.advanced(by: 1) - continue - } - - // Do not break before or after quotation marks, such as '"'. - // LB19: × QU - // QU × - if prev1 == .QU || curr == .QU { - setBreak(.prohibited, 19) - index = index.advanced(by: 1) - continue - } - - // Break before and after unresolved CB. - // LB20: ÷ CB - // CB ÷ - // - // In flutter web, we use this as an object-replacement character for - // placeholders. - if prev1 == .CB || curr == .CB { - setBreak(.opportunity, 20) - index = index.advanced(by: 1) - continue - } - - // Do not break before hyphen-minus, other hyphens, fixed-width spaces, - // small kana, and other non-starters, or after acute accents. - // LB21: × BA - // × HY - // × NS - // BB × - if curr == .BA || curr == .HY || curr == .NS || prev1 == .BB { - setBreak(.prohibited, 21) - index = index.advanced(by: 1) - continue - } - - // Don't break after Hebrew + Hyphen. - // LB21a: HL (HY | BA) × - if prev2 == .HL && (prev1 == .HY || prev1 == .BA) { - setBreak(.prohibited, 21) - index = index.advanced(by: 1) - continue - } - - // Don't break between Solidus and Hebrew letters. - // LB21b: SY × HL - if prev1 == .SY && curr == .HL { - setBreak(.prohibited, 21) - index = index.advanced(by: 1) - continue - } - - // Do not break before ellipses. - // LB22: × IN - if curr == .IN { - setBreak(.prohibited, 22) - index = index.advanced(by: 1) - continue - } - - // Do not break between digits and letters. - // LB23: (AL | HL) × NU - // NU × (AL | HL) - if (_isALorHL(prev1) && curr == .NU) || (prev1 == .NU && _isALorHL(curr)) { - setBreak(.prohibited, 23) - index = index.advanced(by: 1) - continue - } - - // Do not break between numeric prefixes and ideographs, or between - // ideographs and numeric postfixes. - // LB23a: PR × (ID | EB | EM) - if prev1 == .PR && (curr == .ID || curr == .EB || curr == .EM) { - setBreak(.prohibited, 23) - index = index.advanced(by: 1) - continue - } - // LB23a: (ID | EB | EM) × PO - if (prev1 == .ID || prev1 == .EB || prev1 == .EM) && curr == .PO { - setBreak(.prohibited, 23) - index = index.advanced(by: 1) - continue - } - - // Do not break between numeric prefix/postfix and letters, or between - // letters and prefix/postfix. - // LB24: (PR | PO) × (AL | HL) - if (prev1 == .PR || prev1 == .PO) && _isALorHL(curr) { - setBreak(.prohibited, 24) - index = index.advanced(by: 1) - continue - } - // LB24: (AL | HL) × (PR | PO) - if _isALorHL(prev1) && (curr == .PR || curr == .PO) { - setBreak(.prohibited, 24) - index = index.advanced(by: 1) - continue - } - - // Do not break between the following pairs of classes relevant to numbers. - // LB25: (CL | CP | NU) × (PO | PR) - if (prev1 == .CL || prev1 == .CP || prev1 == .NU) && (curr == .PO || curr == .PR) { - setBreak(.prohibited, 25) - index = index.advanced(by: 1) - continue - } - // LB25: (PO | PR) × OP - if (prev1 == .PO || prev1 == .PR) && curr == .OP { - setBreak(.prohibited, 25) - index = index.advanced(by: 1) - continue - } - // LB25: (PO | PR | HY | IS | NU | SY) × NU - if (prev1 == .PO || prev1 == .PR || prev1 == .HY || prev1 == .IS || prev1 == .NU - || prev1 == .SY) && curr == .NU - { - setBreak(.prohibited, 25) - index = index.advanced(by: 1) - continue - } - - // Do not break a Korean syllable. - // LB26: JL × (JL | JV | H2 | H3) - if prev1 == .JL && (curr == .JL || curr == .JV || curr == .H2 || curr == .H3) { - setBreak(.prohibited, 26) - index = index.advanced(by: 1) - continue - } - // LB26: (JV | H2) × (JV | JT) - if (prev1 == .JV || prev1 == .H2) && (curr == .JV || curr == .JT) { - setBreak(.prohibited, 26) - index = index.advanced(by: 1) - continue - } - // LB26: (JT | H3) × JT - if (prev1 == .JT || prev1 == .H3) && curr == .JT { - setBreak(.prohibited, 26) - index = index.advanced(by: 1) - continue - } - - // Treat a Korean Syllable Block the same as ID. - // LB27: (JL | JV | JT | H2 | H3) × PO - if _isKoreanSyllable(prev1) && curr == .PO { - setBreak(.prohibited, 27) - index = index.advanced(by: 1) - continue - } - // LB27: PR × (JL | JV | JT | H2 | H3) - if prev1 == .PR && _isKoreanSyllable(curr) { - setBreak(.prohibited, 27) - index = index.advanced(by: 1) - continue - } - - // Do not break between alphabetics. - // LB28: (AL | HL) × (AL | HL) - if _isALorHL(prev1) && _isALorHL(curr) { - setBreak(.prohibited, 28) - index = index.advanced(by: 1) - continue - } - - // Do not break between numeric punctuation and alphabetics ("e.g."). - // LB29: IS × (AL | HL) - if prev1 == .IS && _isALorHL(curr) { - setBreak(.prohibited, 29) - index = index.advanced(by: 1) - continue - } - - // Do not break between letters, numbers, or ordinary symbols and opening or - // closing parentheses. - // LB30: (AL | HL | NU) × OP - // - // LB30 requires that we exclude characters that have an Eastern Asian width - // property of value F, W or H classes. - if (_isALorHL(prev1) || prev1 == .NU) && curr == .OP - && !_hasEastAsianWidthFWH(index.codePoint(in: text)) - { - setBreak(.prohibited, 30) - index = index.advanced(by: 1) - continue - } - // LB30: CP × (AL | HL | NU) - if prev1 == .CP - && !_hasEastAsianWidthFWH( - index.advanced(by: -1).codePoint(in: text) - ) && (_isALorHL(curr) || curr == .NU) - { - setBreak(.prohibited, 30) - index = index.advanced(by: 1) - continue - } - - // Break between two regional indicator symbols if and only if there are an - // even number of regional indicators preceding the position of the break. - // LB30a: sot (RI RI)* RI × RI - // [^RI] (RI RI)* RI × RI - if curr == .RI { - if regionalIndicatorCount % 2 == 1 { - setBreak(.prohibited, 30) - } else { - setBreak(.opportunity, 30) - } - index = index.advanced(by: 1) - continue - } - - // Do not break between an emoji base and an emoji modifier. - // LB30b: EB × EM - if prev1 == .EB && curr == .EM { - setBreak(.prohibited, 30) - index = index.advanced(by: 1) - continue - } - - // Break everywhere else. - // LB31: ALL ÷ - // ÷ ALL - setBreak(.opportunity, 31) - index = index.advanced(by: 1) - } - - // Always break at the end of text. - // LB3: ! eot - setBreak(.endOfText, 3) - - return fragments -} diff --git a/Sources/ShaftWeb/Text/Measurement.swift b/Sources/ShaftWeb/Text/Measurement.swift deleted file mode 100644 index 5f34245..0000000 --- a/Sources/ShaftWeb/Text/Measurement.swift +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import JavaScriptKit -import Shaft - -// TODO(yjbanov): this is a hack we use to compute ideographic baseline; this -// number is the ratio ideographic/alphabetic for font Ahem, -// which matches the Flutter number. It may be completely wrong -// for any other font. We'll need to eventually fix this. That -// said Flutter doesn't seem to use ideographic baseline for -// anything as of this writing. -let baselineRatioHack: Float = 1.1662499904632568 - -/// Hosts ruler DOM elements in a hidden container under [DomManager.renderingHost]. -class RulerHost { - init() { - let style = _rulerHost.style.object! - style.position = "fixed" - style.visibility = "hidden" - style.overflow = "hidden" - style.top = "0" - style.left = "0" - style.width = "0" - style.height = "0" - - // TODO(mdebbar): There could be multiple views with multiple rendering hosts. - // https://github.com/flutter/flutter/issues/137344 - // let renderingHost = EnginePlatformDispatcher.instance.implicitView!.dom.renderingHost - let renderingHost = domDocument.body.object! - let _ = renderingHost.appendChild!(_rulerHost) - // registerHotRestartListener(dispose) - } - - /// Hosts a cache of rulers that measure text. - /// - /// This element exists purely for organizational purposes. Otherwise the - /// rulers would be attached to the `` element polluting the element - /// tree and making it hard to navigate. It does not serve any functional - /// purpose. - private let _rulerHost = domDocument.createElement("flt-ruler-host") - - /// Releases the resources used by this [RulerHost]. - /// - /// After this is called, this object is no longer usable. - func dispose() { - let _ = _rulerHost.remove() - } - - /// Adds an element used for measuring text as a child of [_rulerHost]. - func addElement(_ element: JSObject) { - let _ = _rulerHost.append(element) - } -} - -// These global variables are used to memoize calls to [measureSubstring]. They -// are used to remember the last arguments passed to it, and the last return -// value. -// They are being initialized so that the compiler knows they'll never be null. -private var _lastStart = TextIndex(utf16Offset: -1) -private var _lastEnd = TextIndex(utf16Offset: -1) -private var _lastText = "" -private var _lastCssFont = "" -private var _lastWidth: Float = -1.0 - -/// Measures the width of the substring of [text] starting from the index -/// [start] (inclusive) to [end] (exclusive). -/// -/// This method assumes that the correct font has already been set on -/// [canvasContext]. -func measureSubstring( - _ canvasContext: JSValue, - _ text: String, - _ start: TextIndex, - _ end: TextIndex, - letterSpacing: Float? = nil -) -> Float { - assert(start >= .zero) - assert(start <= end) - assert(end <= TextIndex(utf16Offset: text.utf16.count)) - - if start == end { - return 0 - } - - let cssFont = canvasContext.font.string! - var width: Float - - // TODO(mdebbar): Explore caching all widths in a map, not only the last one. - if start == _lastStart && end == _lastEnd && text == _lastText && cssFont == _lastCssFont { - // Reuse the previously calculated width if all factors that affect width - // are unchanged. The only exception is letter-spacing. We always add - // letter-spacing to the width later below. - width = _lastWidth - } else { - let sub = - start == .zero && end == TextIndex(utf16Offset: text.utf16.count) - ? text - : (start.. Float { - return (width * 100).rounded() / 100 -} diff --git a/Sources/ShaftWeb/Text/Ruler.swift b/Sources/ShaftWeb/Text/Ruler.swift deleted file mode 100644 index a9224ec..0000000 --- a/Sources/ShaftWeb/Text/Ruler.swift +++ /dev/null @@ -1,203 +0,0 @@ -import Foundation -import JavaScriptKit -import Shaft - -let domDocument = JSObject.global.document - -func buildCssFontString( - fontStyle: FontStyle?, - fontWeight: FontWeight?, - fontSize: Float?, - fontFamilies: [String] -) -> String { - let cssFontStyle = fontStyle?.toCssString() ?? StyleManager.defaultFontStyle - let cssFontWeight = fontWeight?.toCssString() ?? StyleManager.defaultFontWeight - let cssFontSize = Int(floor(fontSize ?? StyleManager.defaultFontSize)) - let cssFontFamily = canonicalizeFontFamily(fontFamilies) - - return "\(cssFontStyle) \(cssFontWeight) \(cssFontSize)px \(cssFontFamily)" -} - -/// Contains all styles that have an effect on the height of text. -/// -/// This is useful as a cache key for [TextHeightRuler]. -struct TextHeightStyle: Equatable, Hashable { - let fontFamilies: [String] - let fontSize: Float - let height: Float? - // let fontFeatures: [FontFeature]? - // let fontVariations: [FontVariation]? - - init( - fontFamilies: [String], - fontSize: Float, - height: Float?, - // fontFeatures: [FontFeature]?, - // fontVariations: [FontVariation]? - ) { - self.fontFamilies = fontFamilies - self.fontSize = fontSize - self.height = height - // self.fontFeatures = fontFeatures - // self.fontVariations = fontVariations - } - -} - -/// Provides text dimensions found on [_element]. The idea behind this class is -/// to allow the [ParagraphRuler] to mutate multiple dom elements and allow -/// consumers to lazily read the measurements. -/// -/// The [ParagraphRuler] would have multiple instances of [TextDimensions] with -/// different backing elements for different types of measurements. When a -/// measurement is needed, the [ParagraphRuler] would mutate all the backing -/// elements at once. The consumer of the ruler can later read those -/// measurements. -/// -/// The rationale behind this is to minimize browser reflows by batching dom -/// writes first, then performing all the reads. -class TextDimensions { - fileprivate var _element: JSObject - private var _cachedBoundingClientRect: JSObject? - - init(_ element: JSObject) { - self._element = element - } - - private func _invalidateBoundsCache() { - _cachedBoundingClientRect = nil - } - - func forceSingleLine() { - _element.style.object!.whiteSpace = "pre" - } - - /// Sets text of contents to a single space character to measure empty text. - func updateTextToSpace() { - _invalidateBoundsCache() - _element.textContent = " " - } - - func applyHeightStyle(_ textHeightStyle: TextHeightStyle) { - let fontFamilies = textHeightStyle.fontFamilies - let fontSize = textHeightStyle.fontSize - let style = _element.style.object! - style.fontSize = .string("\(Int(floor(fontSize)))px") - style.fontFamily = .string(canonicalizeFontFamily(fontFamilies)) - - let height = textHeightStyle.height - // Workaround the rounding introduced by https://github.com/flutter/flutter/issues/122066 - // in tests. - let effectiveLineHeight = height ?? (fontFamilies.first == "FlutterTest" ? 1.0 : nil) - if let effectiveLineHeight = effectiveLineHeight { - style.lineHeight = .string(effectiveLineHeight.description) - } - _invalidateBoundsCache() - } - - /// Appends element and probe to hostElement that is set up for a specific - /// TextStyle. - func appendToHost(_ hostElement: JSObject) { - let _ = hostElement.append!(_element) - _invalidateBoundsCache() - } - - private func _readAndCacheMetrics() -> JSObject { - if let cached = _cachedBoundingClientRect { - return cached - } - let rect = _element.getBoundingClientRect!().object! - _cachedBoundingClientRect = rect - return rect - } - - /// The height of the paragraph being measured. - var height: Double { - var cachedHeight = _readAndCacheMetrics().height.number! - if browser.browserEngine == BrowserEngine.firefox { - // See subpixel rounding bug : - // https://bugzilla.mozilla.org/show_bug.cgi?id=442139 - // This causes bottom of letters such as 'y' to be cutoff and - // incorrect rendering of double underlines. - cachedHeight += 1.0 - } - return cachedHeight - } -} - -/// Performs height measurement for the given [textHeightStyle]. -/// -/// The two results of this ruler's measurement are: -/// -/// 1. [alphabeticBaseline]. -/// 2. [height]. -class TextHeightRuler { - let textHeightStyle: TextHeightStyle - let rulerHost: RulerHost - - // Elements used to measure the line-height metric. - private lazy var _probe: JSObject = _createProbe() - private lazy var _host: JSObject = _createHost() - private let _dimensions = TextDimensions( - JSObject.global.document.createElement("flt-paragraph").object! - ) - - /// The alphabetic baseline for this ruler's [textHeightStyle]. - lazy var alphabeticBaseline: Float = Float( - _probe.getBoundingClientRect!().object!.bottom.number! - ) - - /// The height for this ruler's [textHeightStyle]. - lazy var height: Float = Float(_dimensions.height) - - init(_ textHeightStyle: TextHeightStyle, _ rulerHost: RulerHost) { - self.textHeightStyle = textHeightStyle - self.rulerHost = rulerHost - } - - /// Disposes of this ruler and detaches it from the DOM tree. - func dispose() { - let _ = _host.remove!() - } - - private func _createHost() -> JSObject { - let host = createDomHTMLDivElement().object! - let style = host.style.object! - style.visibility = "hidden" - style.position = "absolute" - style.top = "0" - style.left = "0" - style.display = "flex" - style.flexDirection = "row" - style.alignItems = "baseline" - style.margin = "0" - style.border = "0" - style.padding = "0" - - assert( - { - let _ = host.setAttribute!("data-ruler", "line-height") - return true - }() - ) - - _dimensions.applyHeightStyle(textHeightStyle) - - // Force single-line (even if wider than screen) and preserve whitespaces. - _dimensions.forceSingleLine() - - // To measure line-height, all we need is a whitespace. - _dimensions.updateTextToSpace() - - _dimensions.appendToHost(host) - - rulerHost.addElement(host) - return host - } - - private func _createProbe() -> JSObject { - let probe = createDomHTMLDivElement().object! - let _ = _host.append!(probe) - return probe - } -} diff --git a/Sources/ShaftWeb/Text/TextDirection.swift b/Sources/ShaftWeb/Text/TextDirection.swift deleted file mode 100644 index 3dae967..0000000 --- a/Sources/ShaftWeb/Text/TextDirection.swift +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Shaft - -enum FragmentFlow { - /// The fragment flows from left to right regardless of its surroundings. - case ltr - /// The fragment flows from right to left regardless of its surroundings. - case rtl - /// The fragment flows the same as the previous fragment. - /// - /// If it's the first fragment in a line, then it flows the same as the - /// paragraph direction. - /// - /// E.g. digits. - case previous - /// If the previous and next fragments flow in the same direction, then this - /// fragment flows in that same direction. Otherwise, it flows the same as the - /// paragraph direction. - /// - /// E.g. spaces, symbols. - case sandwich -} - -/// Splits [text] into fragments based on directionality. -class BidiFragmenter: TextFragmenter { - init(_ text: String) { - self.text = text - } - - let text: String - - func fragment() -> [BidiFragment] { - return _computeBidiFragments(text) - } -} - -class BidiFragment: TextFragment { - init( - _ start: TextIndex, - _ end: TextIndex, - _ textDirection: TextDirection?, - _ fragmentFlow: FragmentFlow - ) { - self.textDirection = textDirection - self.fragmentFlow = fragmentFlow - self.start = start - self.end = end - } - - let start: TextIndex - let end: TextIndex - let textDirection: TextDirection? - let fragmentFlow: FragmentFlow -} - -// This data was taken from the source code of the Closure library: -// -// - https://github.com/google/closure-library/blob/9d24a6c1809a671c2e54c328897ebeae15a6d172/closure/goog/i18n/bidi.js#L203-L234 -let _textDirectionLookup: UnicodePropertyLookup = { - let ranges: [UnicodeRange] = [ - // LTR - UnicodeRange(0x41, 0x5A, .ltr), // A-Z - UnicodeRange(0x61, 0x7A, .ltr), // a-z - UnicodeRange(0x00C0, 0x00D6, .ltr), - UnicodeRange(0x00D8, 0x00F6, .ltr), - UnicodeRange(0x00F8, 0x02B8, .ltr), - UnicodeRange(0x0300, 0x0590, .ltr), - // RTL - UnicodeRange(0x0591, 0x06EF, .rtl), - UnicodeRange(0x06FA, 0x08FF, .rtl), - // LTR - UnicodeRange(0x0900, 0x1FFF, .ltr), - UnicodeRange(0x200E, 0x200E, .ltr), - // RTL - UnicodeRange(0x200F, 0x200F, .rtl), - // LTR - UnicodeRange(0x2C00, 0xD801, .ltr), - // RTL - UnicodeRange(0xD802, 0xD803, .rtl), - // LTR - UnicodeRange(0xD804, 0xD839, .ltr), - // RTL - UnicodeRange(0xD83A, 0xD83B, .rtl), - // LTR - UnicodeRange(0xD83C, 0xDBFF, .ltr), - UnicodeRange(0xF900, 0xFB1C, .ltr), - // RTL - UnicodeRange(0xFB1D, 0xFDFF, .rtl), - // LTR - UnicodeRange(0xFE00, 0xFE6F, .ltr), - // RTL - UnicodeRange(0xFE70, 0xFEFC, .rtl), - // LTR - UnicodeRange(0xFEFD, 0xFFFF, .ltr), - ] - return UnicodePropertyLookup(ranges, nil) -}() - -func _computeBidiFragments(_ text: String) -> [BidiFragment] { - var fragments = [BidiFragment]() - - if text.isEmpty { - fragments.append( - BidiFragment( - .zero, - .zero, - nil, - .previous - ) - ) - return fragments - } - - var fragmentStart = TextIndex.zero - var textDirection = _getTextDirection(text, .zero) - var fragmentFlow = _getFragmentFlow(text, .zero) - - for i in 1.. TextDirection? { - let codePoint = getCodePoint(text, i)! - if _isDigit(codePoint) || _isMashriqiDigit(codePoint) { - // A sequence of regular digits or Mashriqi digits always goes from left to right - // regardless of their fragment flow direction. - return .ltr - } - - let textDirection = _textDirectionLookup.findForChar(codePoint) - if textDirection != nil { - return textDirection - } - - return nil -} - -func _getFragmentFlow(_ text: String, _ i: TextIndex) -> FragmentFlow { - let codePoint = getCodePoint(text, i)! - if _isDigit(codePoint) { - return .previous - } - if _isMashriqiDigit(codePoint) { - return .rtl - } - - let textDirection = _textDirectionLookup.findForChar(codePoint) - switch textDirection { - case .ltr: - return .ltr - case .rtl: - return .rtl - case nil: - return .sandwich - } -} - -func _isDigit(_ codePoint: Int) -> Bool { - return codePoint >= kChar_0 && codePoint <= kChar_9 -} - -func _isMashriqiDigit(_ codePoint: Int) -> Bool { - return codePoint >= kMashriqi_0 && codePoint <= kMashriqi_9 -} diff --git a/Sources/ShaftWeb/Text/TextPaintService.swift b/Sources/ShaftWeb/Text/TextPaintService.swift deleted file mode 100644 index 4600323..0000000 --- a/Sources/ShaftWeb/Text/TextPaintService.swift +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Shaft - -/// Responsible for painting a [CanvasParagraph] on a [BitmapCanvas]. -class TextPaintService { - init(_ paragraph: CanvasParagraph) { - self.paragraph = paragraph - } - - unowned let paragraph: CanvasParagraph - - func paint(canvas: Canvas2DCanvas, offset: Offset) { - // Loop through all the lines, for each line, loop through all fragments and - // paint them. The fragment objects have enough information to be painted - // individually. - let lines = paragraph.lines - - for line in lines { - for fragment in line.fragments { - _paintBackground(canvas: canvas, offset: offset, fragment: fragment) - _paintText(canvas: canvas, offset: offset, line: line, fragment: fragment) - } - } - } - - func _paintBackground( - canvas: Canvas2DCanvas, - offset: Offset, - fragment: LayoutFragment - ) { - if fragment.isPlaceholder { - return - } - - // Paint the background of the box, if the span has a background. - if let background = fragment.style.background { - let rect = fragment.toPaintingTextBox().toRect() - if !rect.isEmpty { - canvas.drawRect(rect.shift(offset), background) - } - } - } - - func _paintText( - canvas: Canvas2DCanvas, - offset: Offset, - line: ParagraphLine, - fragment: LayoutFragment - ) { - // There's no text to paint in placeholder spans. - if fragment.isPlaceholder { - return - } - - // Don't paint the text for space-only boxes. This is just an - // optimization, it doesn't have any effect on the output. - if fragment.isSpaceOnly { - return - } - - _prepareCanvasForFragment(canvas: canvas, fragment: fragment) - let fragmentX = - fragment.textDirection == .ltr - ? fragment.left - : fragment.right - - let x = offset.dx + line.left + fragmentX - let y = offset.dy + line.baseline - - let style = fragment.style - - let text = fragment.getText(paragraph) - canvas.drawText(text, x, y, style: style.foreground?.style, shadows: style.shadows) - } - - func _prepareCanvasForFragment(canvas: Canvas2DCanvas, fragment: LayoutFragment) { - let style = fragment.style - - var paint: Paint - if let foreground = style.foreground { - paint = foreground - } else { - paint = Paint() - if let color = style.color { - paint.color = color - } - } - - canvas.setCssFont(style.cssFontString, textDirection: fragment.textDirection!) - canvas.applyPaint(paint) - } -} diff --git a/Sources/ShaftWeb/Text/UnicodeRange.swift b/Sources/ShaftWeb/Text/UnicodeRange.swift deleted file mode 100644 index 8a34327..0000000 --- a/Sources/ShaftWeb/Text/UnicodeRange.swift +++ /dev/null @@ -1,304 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. -import Shaft - -let kChar_0 = 48 -let kChar_9 = kChar_0 + 9 -let kChar_A = 65 -let kChar_Z = 90 -let kChar_a = 97 -let kChar_z = 122 -let kCharBang = 33 -let kMashriqi_0 = 0x660 -let kMashriqi_9 = kMashriqi_0 + 9 - -enum ComparisonResult { - case inside - case higher - case lower -} - -/// Each instance of UnicodeRange represents a range of unicode characters -/// that are assigned a CharProperty. For example, the following snippet: -/// -/// ```swift -/// UnicodeRange(0x0041, 0x005A, CharProperty.ALetter) -/// ``` -/// -/// is saying that all characters between 0x0041 ("A") and 0x005A ("Z") are -/// assigned the property CharProperty.ALetter. -/// -/// Note that the Unicode spec uses inclusive ranges and we are doing the -/// same here. -class UnicodeRange

{ - let start: Int - let end: Int - let property: P - - init(_ start: Int, _ end: Int, _ property: P) { - self.start = start - self.end = end - self.property = property - } - - /// Compare a value to this range. - /// - /// The return value is either: - /// - lower: The value is lower than the range. - /// - higher: The value is higher than the range - /// - inside: The value is within the range. - func compare(_ value: Int) -> ComparisonResult { - if value < start { - return .lower - } - if value > end { - return .higher - } - return .inside - } -} - -/// Checks whether the given char code is a UTF-16 surrogate. -/// -/// See: -/// - http://www.unicode.org/faq//utf_bom.html#utf16-2 -func isUtf16Surrogate(_ char: Int) -> Bool { - return char & 0xF800 == 0xD800 -} - -/// Combines a pair of UTF-16 surrogate into a single character code point. -/// -/// The surrogate pair is expected to start at index in the text. -/// -/// See: -/// - http://www.unicode.org/faq//utf_bom.html#utf16-3 -func combineSurrogatePair(_ text: String, _ index: TextIndex) -> Int { - let hi = Int(text.utf16[index.index(in: text)]) - let lo = Int(text.utf16[index.advanced(by: 1).index(in: text)]) - - let x = (hi & ((1 << 6) - 1)) << 10 | lo & ((1 << 10) - 1) - let w = (hi >> 6) & ((1 << 5) - 1) - let u = w + 1 - return u << 16 | x -} - -/// Returns the code point from text at index and handles surrogate pairs -/// for cases that involve two UTF-16 codes. -func getCodePoint(_ text: String, _ index: TextIndex) -> Int? { - if index < .zero || index >= TextIndex(utf16Offset: text.utf16.count) { - return nil - } - - let char = Int(text.utf16[index.index(in: text)]) - if isUtf16Surrogate(char) && index < TextIndex(utf16Offset: text.utf16.count - 1) { - return combineSurrogatePair(text, index) - } - return char -} - -/// Given a list of UnicodeRanges, this class performs efficient lookup -/// to find which range a value falls into. -/// -/// The lookup algorithm expects the ranges to have the following constraints: -/// - Be sorted. -/// - No overlap between the ranges. -/// - Gaps between ranges are ok. -/// -/// This is used in the context of unicode to find out what property a letter -/// has. The properties are then used to decide word boundaries, line break -/// opportunities, etc. -class UnicodePropertyLookup

{ - /// The list of unicode ranges and their associated properties. - let ranges: [UnicodeRange

] - - /// The default property to use when a character doesn't belong in any - /// known range. - let defaultProperty: P - - /// Cache for lookup results. - private var cache = [Int: P]() - - init(_ ranges: [UnicodeRange

], _ defaultProperty: P) { - self.ranges = ranges - self.defaultProperty = defaultProperty - } - - /// Creates a UnicodePropertyLookup from packed line break data. - static func fromPackedData( - _ packedData: String, - _ singleRangesCount: Int, - _ propertyEnumValues: [P], - _ defaultProperty: P - ) -> UnicodePropertyLookup

{ - return UnicodePropertyLookup

( - unpackProperties(packedData, singleRangesCount, propertyEnumValues), - defaultProperty - ) - } - - /// Take a text and an index, and returns the property of the character - /// located at that index. - /// - /// If the index is out of range, nil will be returned. - func find(_ text: String, _ index: TextIndex) -> P { - guard let codePoint = getCodePoint(text, index) else { - return defaultProperty - } - return findForChar(codePoint) - } - - /// Takes one character as an integer code unit and returns its property. - /// - /// If a property can't be found for the given character, then the default - /// property will be returned. - func findForChar(_ char: Int?) -> P { - guard let char = char else { - return defaultProperty - } - - if let cacheHit = cache[char] { - return cacheHit - } - - let rangeIndex = binarySearch(char) - let result = rangeIndex == -1 ? defaultProperty : ranges[rangeIndex].property - // Cache the result. - cache[char] = result - return result - } - - private func binarySearch(_ value: Int) -> Int { - var min = 0 - var max = ranges.count - while min < max { - let mid = min + ((max - min) >> 1) - let range = ranges[mid] - switch range.compare(value) { - case .higher: - min = mid + 1 - case .lower: - max = mid - case .inside: - return mid - } - } - return -1 - } -} - -func unpackProperties

( - _ packedData: String, - _ singleRangesCount: Int, - _ propertyEnumValues: [P] -) -> [UnicodeRange

] { - // Packed data is mostly structured in chunks of 9 characters each: - // - // * [0..3]: Range start, encoded as a base36 integer. - // * [4..7]: Range end, encoded as a base36 integer. - // * [8]: Index of the property enum value, encoded as a single letter. - // - // When the range is a single number (i.e. range start == range end), it gets - // packed more efficiently in a chunk of 6 characters: - // - // * [0..3]: Range start (and range end), encoded as a base 36 integer. - // * [4]: "!" to indicate that there's no range end. - // * [5]: Index of the property enum value, encoded as a single letter. - - // `packedData.length + singleRangesCount * 3` would have been the size of the - // packed data if the efficient packing of single-range items wasn't applied. - assert((packedData.count + singleRangesCount * 3) % 9 == 0) - - var ranges = [UnicodeRange

]() - let dataLength = packedData.count - var i = 0 - while i < dataLength { - let rangeStart = consumeInt(packedData, i) - i += 4 - - var rangeEnd: Int - if Int(packedData.utf16[packedData.utf16.index(packedData.utf16.startIndex, offsetBy: i)]) - == kCharBang - { - rangeEnd = rangeStart - i += 1 - } else { - rangeEnd = consumeInt(packedData, i) - i += 4 - } - let charCode = Int( - packedData.utf16[packedData.utf16.index(packedData.utf16.startIndex, offsetBy: i)] - ) - let property = propertyEnumValues[getEnumIndexFromPackedValue(charCode)] - i += 1 - - ranges.append(UnicodeRange

(rangeStart, rangeEnd, property)) - } - return ranges -} - -func getEnumIndexFromPackedValue(_ charCode: Int) -> Int { - // This has to stay in sync with [EnumValue.serialized] in - // `tool/unicode_sync_script.dart`. - - assert( - (charCode >= kChar_A && charCode <= kChar_Z) || (charCode >= kChar_a && charCode <= kChar_z) - ) - - // Uppercase letters were assigned to the first 26 enum values. - if charCode <= kChar_Z { - return charCode - kChar_A - } - // Lowercase letters were assigned to enum values above 26. - return 26 + charCode - kChar_a -} - -func consumeInt(_ packedData: String, _ index: Int) -> Int { - // The implementation is equivalent to: - // - // ```swift - // return Int(packedData.substring(from: index, to: index + 4), radix: 36) - // ``` - // - // But using substring is slow when called too many times. This custom - // implementation makes the unpacking 25%-45% faster than using substring. - let digit0 = getIntFromCharCode( - Int( - packedData.utf16[ - packedData.utf16.index(packedData.utf16.startIndex, offsetBy: index + 3) - ] - ) - ) - let digit1 = getIntFromCharCode( - Int( - packedData.utf16[ - packedData.utf16.index(packedData.utf16.startIndex, offsetBy: index + 2) - ] - ) - ) - let digit2 = getIntFromCharCode( - Int( - packedData.utf16[ - packedData.utf16.index(packedData.utf16.startIndex, offsetBy: index + 1) - ] - ) - ) - let digit3 = getIntFromCharCode( - Int(packedData.utf16[packedData.utf16.index(packedData.utf16.startIndex, offsetBy: index)]) - ) - return digit0 + (digit1 * 36) + (digit2 * 36 * 36) + (digit3 * 36 * 36 * 36) -} - -/// Does the same thing as Int.parse(str, 36) but takes only a single -/// character as a charCode integer. -func getIntFromCharCode(_ charCode: Int) -> Int { - assert( - (charCode >= kChar_0 && charCode <= kChar_9) || (charCode >= kChar_a && charCode <= kChar_z) - ) - - if charCode <= kChar_9 { - return charCode - kChar_0 - } - // "a" starts from 10 and remaining letters go up from there. - return charCode - kChar_a + 10 -} diff --git a/Sources/ShaftWeb/Text/WordBreakProperties.swift b/Sources/ShaftWeb/Text/WordBreakProperties.swift deleted file mode 100644 index f12f38d..0000000 --- a/Sources/ShaftWeb/Text/WordBreakProperties.swift +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2022 Google LLC -// -// For terms of use, see https://www.unicode.org/copyright.html - -/// For an explanation of these enum values, see: -/// -/// * http://unicode.org/reports/tr29/#Table_Word_Break_Property_Values -enum WordCharProperty: CaseIterable { - case DoubleQuote // serialized as "A" - case SingleQuote // serialized as "B" - case HebrewLetter // serialized as "C" - case CR // serialized as "D" - case LF // serialized as "E" - case Newline // serialized as "F" - case Extend // serialized as "G" - case RegionalIndicator // serialized as "H" - case Format // serialized as "I" - case Katakana // serialized as "J" - case ALetter // serialized as "K" - case MidLetter // serialized as "L" - case MidNum // serialized as "M" - case MidNumLet // serialized as "N" - case Numeric // serialized as "O" - case ExtendNumLet // serialized as "P" - case ZWJ // serialized as "Q" - case WSegSpace // serialized as "R" - case Unknown // serialized as "S" -} - -let packedWordBreakProperties = - "000a!E000b000cF000d!D000w!R000y!A0013!B0018!M001a!N001c001lO001m!L001n!M001t002iK002n!P002p003eK003p!F004q!K004t!I0051!K0053!L0056!K005c005yK0060006uK006w00k7K00ke00lbK00lc00ofG00og00okK00om00onK00oq00otK00ou!M00ov!K00p2!K00p3!L00p400p6K00p8!K00pa00ptK00pv00s5K00s700w1K00w300w9G00wa010vK010x011yK01210124K0126!K0127!L0128013cK013d!M013e!K013l014tG014v!G014x014yG01500151G0153!G015c0162C0167016aC016b!K016c!L016o016tI01700171M0174017eG017g!I017k018qK018r019bG019c019lO019n!O019o!M019q019rK019s!G019t01cjK01cl!K01cm01csG01ct!I01cv01d0G01d101d2K01d301d4G01d601d9G01da01dbK01dc01dlO01dm01doK01dr!K01e7!I01e8!K01e9!G01ea01f3K01f401fuG01fx01idK01ie01ioG01ip!K01j401jdO01je01kaK01kb01kjG01kk01klK01ko!M01kq!K01kt!G01kw01lhK01li01llG01lm!K01ln01lvG01lw!K01lx01lzG01m0!K01m101m5G01mo01ncK01nd01nfG01nk01nuK01pc01pwK01py01qfK01qr01r5G01r6!I01r701s3G01s401tlK01tm01toG01tp!K01tq01u7G01u8!K01u901ufG01ug01upK01uq01urG01uu01v3O01v501vkK01vl01vnG01vp01vwK01vz01w0K01w301woK01wq01wwK01wy!K01x201x5K01x8!G01x9!K01xa01xgG01xj01xkG01xn01xpG01xq!K01xz!G01y401y5K01y701y9K01ya01ybG01ye01ynO01yo01ypK01z0!K01z2!G01z501z7G01z901zeK01zj01zkK01zn0208K020a020gK020i020jK020l020mK020o020pK020s!G020u020yG02130214G02170219G021d!G021l021oK021q!K021y0227O02280229G022a022cK022d!G022p022rG022t0231K02330235K0237023sK023u0240K02420243K02450249K024c!G024d!K024e024lG024n024pG024r024tG024w!K025c025dK025e025fG025i025rO0261!K02620267G0269026bG026d026kK026n026oK026r027cK027e027kK027m027nK027p027tK027w!G027x!K027y0284G02870288G028b028dG028l028nG028s028tK028v028xK028y028zG0292029bO029d!K029u!G029v!K029x02a2K02a602a8K02aa02adK02ah02aiK02ak!K02am02anK02ar02asK02aw02ayK02b202bdK02bi02bmG02bq02bsG02bu02bxG02c0!K02c7!G02cm02cvO02dc02dgG02dh02doK02dq02dsK02du02egK02ei02exK02f1!K02f202f8G02fa02fcG02fe02fhG02fp02fqG02fs02fuK02g002g1K02g202g3G02g602gfO02gw!K02gx02gzG02h102h8K02ha02hcK02he02i0K02i202ibK02id02ihK02ik!G02il!K02im02isG02iu02iwG02iy02j1G02j902jaG02ji!K02jk02jlK02jm02jnG02jq02jzO02k102k2K02kg02kjG02kk02ksK02ku02kwK02ky02m2K02m302m4G02m5!K02m602mcG02me02mgG02mi02mlG02mm!K02ms02muK02mv!G02n302n5K02n602n7G02na02njO02nu02nzK02o102o3G02o502omK02oq02pdK02pf02pnK02pp!K02ps02pyK02q2!G02q702qcG02qe!G02qg02qnG02qu02r3O02r602r7G02sx!G02t002t6G02tj02tqG02ts02u1O02wh!G02wk02wsG02x402x9G02xc02xlO02yo!K02zc02zdG02zk02ztO0305!G0307!G0309!G030e030fG030g030nK030p031oK031t032cG032e032fG032g032kK032l032vG032x033wG0346!G036z037iG037k037tO03860389G038e038gG038i038kG038n038tG038x0390G039e039pG039r!G039s03a1O03a203a5G03a803b9K03bb!K03bh!K03bk03cqK03cs03m0K03m203m5K03m803meK03mg!K03mi03mlK03mo03nsK03nu03nxK03o003owK03oy03p1K03p403paK03pc!K03pe03phK03pk03pyK03q003rkK03rm03rpK03rs03tmK03tp03trG03uo03v3K03vk03xxK03y003y5K03y904fgK04fj04fzK04g0!R04g104gqK04gw04iyK04j204jcK04jk04jwK04jy04k1K04k204k4G04kg04kxK04ky04l0G04lc04ltK04lu04lvG04m804mkK04mm04moK04mq04mrG04ok04pfG04pp!G04ps04q1O04qz04r1G04r2!I04r404rdO04rk04u0K04u804ucK04ud04ueG04uf04vcK04vd!G04ve!K04vk04xhK04xs04ymK04yo04yzG04z404zfG04zq04zzO053k053tO054w055iK055j055nG0579057iG057k058cG058f!G058g058pO058w0595O059s05a8G05c005c4G05c505dfK05dg05dwG05dx05e3K05e805ehO05ez05f7G05fk05fmG05fn05ggK05gh05gtG05gu05gvK05gw05h5O05h605idK05ie05irG05j405k3K05k405knG05kw05l5O05l905lbK05lc05llO05lm05mlK05mo05mwK05n405oaK05od05ofK05ow05oyG05p005pkG05pl05poK05pp!G05pq05pvK05pw!G05px05pyK05pz05q1G05q2!K05q805vjK05vk05x5G05x705xbG05xc0651K06540659K065c066dK066g066lK066o066vK066x!K066z!K0671!K0673067xK0680069gK069i069oK069q!K069u069wK069y06a4K06a806abK06ae06ajK06ao06b0K06b606b8K06ba06bgK06bk06bqR06bs06buR06bw!G06bx!Q06by06bzI06c806c9N06ck!N06cn!L06co06cpF06cq06cuI06cv!P06db06dcP06dg!M06dw!P06e7!R06e806ecI06ee06enI06ep!K06f3!K06fk06fwK06hc06i8G06iq!K06iv!K06iy06j7K06j9!K06jd06jhK06jo!K06jq!K06js!K06ju06jxK06jz06k9K06kc06kfK06kl06kpK06ku!K06lc06mgK079207ahK08ow08q6K08q808riK08rk08v8K08vf08viK08vj08vlG08vm08vnK08w008x1K08x3!K08x9!K08xc08yvK08z3!K08zj!G08zk0906K090g090mK090o090uK090w0912K0914091aK091c091iK091k091qK091s091yK09200926K09280933G094f!K09hc!R09hh!K09ii09inG09ip09itJ09iz09j0K09ll09lmG09ln09loJ09ls09oaJ09oc09ofJ09ol09prK09pt09seK09sw09trK09v409vjJ0a1c0a2mJ0a2o0a53J0vls0wi4K0wk00wl9K0wlc0wssK0wsw0wtbK0wtc0wtlO0wtm0wtnK0wu80wviK0wvj0wvmG0wvo0wvxG0wvz0wwtK0wwu0wwvG0www0wz3K0wz40wz5G0wzs0x4vK0x4y0x56K0x6d0x6pK0x6q!G0x6r0x6tK0x6u!G0x6v0x6yK0x6z!G0x700x7mK0x7n0x7rG0x7w!G0x8g0x9vK0xa80xa9G0xaa0xbnK0xbo0xc5G0xcg0xcpO0xcw0xddG0xde0xdjK0xdn!K0xdp0xdqK0xdr!G0xds0xe1O0xe20xetK0xeu0xf1G0xf40xfqK0xfr0xg3G0xgg0xh8K0xhc0xhfG0xhg0xiqK0xir0xj4G0xjj!K0xjk0xjtO0xk5!G0xkg0xkpO0xkw0xm0K0xm10xmeG0xmo0xmqK0xmr!G0xms0xmzK0xn00xn1G0xn40xndO0xob0xodG0xps!G0xpu0xpwG0xpz0xq0G0xq60xq7G0xq9!G0xr40xreK0xrf0xrjG0xrm0xroK0xrp0xrqG0xs10xs6K0xs90xseK0xsh0xsmK0xsw0xt2K0xt40xtaK0xtc0xuxK0xv40xyaK0xyb0xyiG0xyk0xylG0xyo0xyxO0xz416lfK16ls16meK16mj16nvK1dkw1dl2K1dlf1dljK1dlp!C1dlq!G1dlr1dm0C1dm21dmeC1dmg1dmkC1dmm!C1dmo1dmpC1dmr1dmsC1dmu1dn3C1dn41dptK1dqr1e0tK1e1c1e33K1e361e4nK1e5s1e63K1e681e6nG1e6o!M1e6r!L1e6s!M1e741e7jG1e7n1e7oP1e8d1e8fP1e8g!M1e8i!N1e8k!M1e8l!L1e9c1e9gK1e9i1ed8K1edb!I1edj!N1edo!M1edq!N1eds1ee1O1ee2!L1ee3!M1ee91eeyK1ef3!P1ef51efuK1eg61ehpJ1ehq1ehrG1ehs1eimK1eiq1eivK1eiy1ej3K1ej61ejbK1eje1ejgK1ek91ekbI1ekg1ekrK1ekt1eliK1elk1em2K1em41em5K1em71emlK1emo1en1K1eo01ereK1etc1eusK1eyl!G1f281f30K1f341f4gK1f4w!G1f5s1f6nK1f711f7uK1f801f91K1f921f96G1f9c1fa5K1fa81fb7K1fbc1fbjK1fbl1fbpK1fcw1fh9K1fhc1fhlO1fhs1firK1fiw1fjvK1fk01fl3K1flc1fmrK1fr41fzqK1g001g0lK1g0w1g13K1g5c1g5hK1g5k!K1g5m1g6tK1g6v1g6wK1g70!K1g731g7pK1g801g8mK1g8w1g9qK1gbk1gc2K1gc41gc5K1gcg1gd1K1gdc1ge1K1gg01ghjK1ghq1ghrK1gjk!K1gjl1gjnG1gjp1gjqG1gjw1gjzG1gk01gk3K1gk51gk7K1gk91gl1K1gl41gl6G1glb!G1gm81gn0K1gn41gnwK1gow1gp3K1gp51gpwK1gpx1gpyG1gqo1gs5K1gsg1gt1K1gtc1gtuK1gu81gupK1gxs1gzsK1h1c1h2qK1h341h4iK1h4w1h5vK1h5w1h5zG1h681h6hO1hfk1hgpK1hgr1hgsG1hgw1hgxK1hj41hjwK1hk7!K1hkg1hl1K1hl21hlcG1ho01hokK1hpc1hpyK1hq81hqaG1hqb1hrrK1hrs1hs6G1ht21htbO1htr1htuG1htv1hv3K1hv41hveG1hvh!I1hvx!I1hw01hwoK1hww1hx5O1hxc1hxeG1hxf1hyeK1hyf1hysG1hyu1hz3O1hz8!K1hz91hzaG1hzb!K1hzk1i0iK1i0j!G1i0m!K1i0w1i0yG1i0z1i2aK1i2b1i2oG1i2p1i2sK1i2x1i30G1i321i33G1i341i3dO1i3e!K1i3g!K1i4g1i4xK1i4z1i5nK1i5o1i5zG1i66!G1i801i86K1i88!K1i8a1i8dK1i8f1i8tK1i8v1i94K1i9c1iamK1ian1iayG1ib41ibdO1ibk1ibnG1ibp1ibwK1ibz1ic0K1ic31icoK1icq1icwK1icy1iczK1id11id5K1id71id8G1id9!K1ida1idgG1idj1idkG1idn1idpG1ids!K1idz!G1ie51ie9K1iea1iebG1iee1iekG1ieo1iesG1iio1ik4K1ik51ikmG1ikn1ikqK1ikw1il5O1ila!G1ilb1ildK1im81injK1ink1io3G1io41io5K1io7!K1iog1iopO1itc1iumK1iun1iutG1iuw1iv4G1ivs1ivvK1ivw1ivxG1iww1iy7K1iy81iyoG1iys!K1iz41izdO1j0g1j1mK1j1n1j1zG1j20!K1j281j2hO1j4t1j57G1j5c1j5lO1jb41jcbK1jcc1jcqG1jfk1jhbK1jhc1jhlO1ji71jieK1jih!K1jik1jirK1jit1jiuK1jiw1jjjK1jjk1jjpG1jjr1jjsG1jjv1jjyG1jjz!K1jk0!G1jk1!K1jk21jk3G1jkg1jkpO1jmo1jmvK1jmy1jo0K1jo11jo7G1joa1jogG1joh!K1joj!K1jok!G1jpc!K1jpd1jpmG1jpn1jqqK1jqr1jqxG1jqy!K1jqz1jr2G1jrb!G1jrk!K1jrl1jrvG1jrw1jt5K1jt61jtlG1jtp!K1juo1jw8K1k3k1k3sK1k3u1k4uK1k4v1k52G1k541k5bG1k5c!K1k5s1k61O1k6q1k7jK1k7m1k87G1k891k8mG1kao1kauK1kaw1kaxK1kaz1kc0K1kc11kc6G1kca!G1kcc1kcdG1kcf1kclG1kcm!K1kcn!G1kcw1kd5O1kdc1kdhK1kdj1kdkK1kdm1kehK1kei1kemG1keo1kepG1ker1kevG1kew!K1kf41kfdO1ko01koiK1koj1komG1kts!K1kw01lllK1log1lriK1ls01lxfK1o1s1oviK1ovk1ovsI1s001sg6K1z401zjsK1zk01zkuK1zkw1zl5O1zo01zotK1zow1zp0G1zpc1zqnK1zqo1zquG1zr41zr7K1zrk1zrtO1zs31zsnK1zst1ztbK20cg20e7K20hs20juK20jz!G20k0!K20k120ljG20lr20luG20lv20m7K20o020o1K20o3!K20o4!G20og20ohG2dc0!J2dlw2dlzJ2fpc2fsaK2fsg2fssK2fsw2ft4K2ftc2ftlK2ftp2ftqG2fts2ftvI2jxh2jxlG2jxp2jxuG2jxv2jy2I2jy32jyaG2jyd2jyjG2jze2jzhG2k3m2k3oG2kg02kicK2kie2kkcK2kke2kkfK2kki!K2kkl2kkmK2kkp2kksK2kku2kl5K2kl7!K2kl92klfK2klh2kn9K2knb2kneK2knh2knoK2knq2knwK2kny2kopK2kor2kouK2kow2kp0K2kp2!K2kp62kpcK2kpe2kytK2kyw2kzkK2kzm2l0aK2l0c2l16K2l182l1wK2l1y2l2sK2l2u2l3iK2l3k2l4eK2l4g2l54K2l562l60K2l622l6qK2l6s2l6zK2l722l8fO2lmo2lo6G2lob2lpoG2lpx!G2lqc!G2lqz2lr3G2lr52lrjG2mtc2mtiG2mtk2mu0G2mu32mu9G2mub2mucG2mue2muiG2n0g2n1oK2n1s2n1yG2n1z2n25K2n282n2hO2n2m!K2ncw2ne3K2ne42ne7G2ne82nehO2oe82ojoK2ok02ok6G2olc2on7K2on82oneG2onf!K2onk2ontO2pkw2pkzK2pl12plrK2plt2pluK2plw!K2plz!K2pm12pmaK2pmc2pmfK2pmh!K2pmj!K2pmq!K2pmv!K2pmx!K2pmz!K2pn12pn3K2pn52pn6K2pn8!K2pnb!K2pnd!K2pnf!K2pnh!K2pnj!K2pnl2pnmK2pno!K2pnr2pnuK2pnw2po2K2po42po7K2po92pocK2poe!K2pog2popK2por2pp7K2ppd2ppfK2pph2pplK2ppn2pq3K2q7k2q89K2q8g2q95K2q9c2qa1K2qcm2qdbH2qrf2qrjG2sc02sc9Ojny9!Ijnz4jo1rGjo5cjobzG" - -let singleWordBreakRangesCount = 231 - -let defaultWordCharProperty = WordCharProperty.Unknown - -let wordLookup = UnicodePropertyLookup.fromPackedData( - packedWordBreakProperties, - singleWordBreakRangesCount, - Array(WordCharProperty.allCases), - defaultWordCharProperty -) diff --git a/Sources/ShaftWeb/Text/WordBreaker.swift b/Sources/ShaftWeb/Text/WordBreaker.swift deleted file mode 100644 index 941dc64..0000000 --- a/Sources/ShaftWeb/Text/WordBreaker.swift +++ /dev/null @@ -1,330 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Shaft - -enum _FindBreakDirection { - case forward - case backward - - var step: Int { - switch self { - case .forward: return 1 - case .backward: return -1 - } - } -} - -/// [WordBreaker] exposes static methods to identify word boundaries. -final class WordBreaker { - /// It starts from [index] and tries to find the next word boundary in [text]. - static func nextBreakIndex(text: String, index: TextIndex) -> TextIndex { - return _findBreakIndex(direction: .forward, text: text, index: index) - } - - /// It starts from [index] and tries to find the previous word boundary in - /// [text]. - static func prevBreakIndex(text: String, index: TextIndex) -> TextIndex { - return _findBreakIndex(direction: .backward, text: text, index: index) - } - - static func _findBreakIndex( - direction: _FindBreakDirection, - text: String, - index: TextIndex - ) -> TextIndex { - var i = index - while i >= .zero && i <= TextIndex(utf16Offset: text.utf16.count) { - i = i + TextIndex(utf16Offset: direction.step) - if _isBreak(text: text, index: i) { - break - } - } - return i.clamped(to: .zero...TextIndex(utf16Offset: text.utf16.count)) - } - - /// Find out if there's a word break between [index - 1] and [index]. - /// http://unicode.org/reports/tr29/#Word_Boundary_Rules - static func _isBreak(text: String?, index: TextIndex) -> Bool { - // Break at the start and end of text. - // WB1: sot ÷ Any - // WB2: Any ÷ eot - if index <= .zero || index >= TextIndex(utf16Offset: text!.utf16.count) { - return true - } - - // Do not break inside surrogate pair - if _isUtf16Surrogate(index.codeUnit(in: text!)) { - return false - } - - let immediateRight = wordLookup.find(text!, index) - var immediateLeft = wordLookup.find(text!, index.advanced(by: -1)) - - // Do not break within CRLF. - // WB3: CR × LF - if immediateLeft == .CR && immediateRight == .LF { - return false - } - - // Otherwise break before and after Newlines (including CR and LF) - // WB3a: (Newline | CR | LF) ÷ - if _oneOf( - value: immediateLeft, - choice1: .Newline, - choice2: .CR, - choice3: .LF - ) { - return true - } - - // WB3b: ÷ (Newline | CR | LF) - if _oneOf( - value: immediateRight, - choice1: .Newline, - choice2: .CR, - choice3: .LF - ) { - return true - } - - // WB3c: ZWJ × \p{Extended_Pictographic} - // TODO(mdebbar): What's the right way to implement this? - - // Keep horizontal whitespace together. - // WB3d: WSegSpace × WSegSpace - if immediateLeft == .WSegSpace && immediateRight == .WSegSpace { - return false - } - - // Ignore Format and Extend characters, except after sot, CR, LF, and - // Newline. - // WB4: X (Extend | Format | ZWJ)* → X - if _oneOf( - value: immediateRight, - choice1: .Extend, - choice2: .Format, - choice3: .ZWJ - ) { - // The Extend|Format|ZWJ character is to the right, so it is attached - // to a character to the left, don't split here - return false - } - - // We've reached the end of an Extend|Format|ZWJ sequence, collapse it. - var l = 0 - while _oneOf( - value: immediateLeft, - choice1: .Extend, - choice2: .Format, - choice3: .ZWJ - ) { - l += 1 - if index.advanced(by: -l - 1) <= .zero { - // Reached the beginning of text. - return true - } - immediateLeft = wordLookup.find(text!, index.advanced(by: -l - 1)) - } - - // Do not break between most letters. - // WB5: (ALetter | Hebrew_Letter) × (ALetter | Hebrew_Letter) - if _isAHLetter(property: immediateLeft) && _isAHLetter(property: immediateRight) { - return false - } - - // Some tests beyond this point require more context. We need to get that - // context while also respecting rule WB4. So ignore Format, Extend and ZWJ. - - // Skip all Format, Extend and ZWJ to the right. - var r = 0 - var nextRight: WordCharProperty? - repeat { - r += 1 - nextRight = wordLookup.find(text!, index.advanced(by: r)) - } while _oneOf( - value: nextRight, - choice1: .Extend, - choice2: .Format, - choice3: .ZWJ - ) - - // Skip all Format, Extend and ZWJ to the left. - var nextLeft: WordCharProperty? - repeat { - l += 1 - nextLeft = wordLookup.find(text!, index.advanced(by: -l - 1)) - } while _oneOf( - value: nextLeft, - choice1: .Extend, - choice2: .Format, - choice3: .ZWJ - ) - - // Do not break letters across certain punctuation. - // WB6: (AHLetter) × (MidLetter | MidNumLet | Single_Quote) (AHLetter) - if _isAHLetter(property: immediateLeft) - && _oneOf( - value: immediateRight, - choice1: .MidLetter, - choice2: .MidNumLet, - choice3: .SingleQuote - ) && _isAHLetter(property: nextRight) - { - return false - } - - // WB7: (AHLetter) (MidLetter | MidNumLet | Single_Quote) × (AHLetter) - if _isAHLetter(property: nextLeft) - && _oneOf( - value: immediateLeft, - choice1: .MidLetter, - choice2: .MidNumLet, - choice3: .SingleQuote - ) && _isAHLetter(property: immediateRight) - { - return false - } - - // WB7a: Hebrew_Letter × Single_Quote - if immediateLeft == .HebrewLetter && immediateRight == .SingleQuote { - return false - } - - // WB7b: Hebrew_Letter × Double_Quote Hebrew_Letter - if immediateLeft == .HebrewLetter && immediateRight == .DoubleQuote - && nextRight == .HebrewLetter - { - return false - } - - // WB7c: Hebrew_Letter Double_Quote × Hebrew_Letter - if nextLeft == .HebrewLetter && immediateLeft == .DoubleQuote - && immediateRight == .HebrewLetter - { - return false - } - - // Do not break within sequences of digits, or digits adjacent to letters - // ("3a", or "A3"). - // WB8: Numeric × Numeric - if immediateLeft == .Numeric && immediateRight == .Numeric { - return false - } - - // WB9: AHLetter × Numeric - if _isAHLetter(property: immediateLeft) && immediateRight == .Numeric { - return false - } - - // WB10: Numeric × AHLetter - if immediateLeft == .Numeric && _isAHLetter(property: immediateRight) { - return false - } - - // Do not break within sequences, such as "3.2" or "3,456.789". - // WB11: Numeric (MidNum | MidNumLet | Single_Quote) × Numeric - if nextLeft == .Numeric - && _oneOf( - value: immediateLeft, - choice1: .MidNum, - choice2: .MidNumLet, - choice3: .SingleQuote - ) && immediateRight == .Numeric - { - return false - } - - // WB12: Numeric × (MidNum | MidNumLet | Single_Quote) Numeric - if immediateLeft == .Numeric - && _oneOf( - value: immediateRight, - choice1: .MidNum, - choice2: .MidNumLet, - choice3: .SingleQuote - ) && nextRight == .Numeric - { - return false - } - - // Do not break between Katakana. - // WB13: Katakana × Katakana - if immediateLeft == .Katakana && immediateRight == .Katakana { - return false - } - - // Do not break from extenders. - // WB13a: (AHLetter | Numeric | Katakana | ExtendNumLet) × ExtendNumLet - if _oneOf( - value: immediateLeft, - choice1: .ALetter, - choice2: .HebrewLetter, - choice3: .Numeric, - choice4: .Katakana, - choice5: .ExtendNumLet - ) && immediateRight == .ExtendNumLet { - return false - } - - // WB13b: ExtendNumLet × (AHLetter | Numeric | Katakana) - if immediateLeft == .ExtendNumLet - && _oneOf( - value: immediateRight, - choice1: .ALetter, - choice2: .HebrewLetter, - choice3: .Numeric, - choice4: .Katakana - ) - { - return false - } - - // Do not break within emoji flag sequences. That is, do not break between - // regional indicator (RI) symbols if there is an odd number of RI - // characters before the break point. - // WB15: sot (RI RI)* RI × RI - // TODO(mdebbar): implement this. - - // WB16: [^RI] (RI RI)* RI × RI - // TODO(mdebbar): implement this. - - // Otherwise, break everywhere (including around ideographs). - // WB999: Any ÷ Any - return true - } - - static func _isUtf16Surrogate(_ value: UInt16) -> Bool { - return (value & 0xF800) == 0xD800 - } - - static func _oneOf( - value: WordCharProperty?, - choice1: WordCharProperty, - choice2: WordCharProperty, - choice3: WordCharProperty? = nil, - choice4: WordCharProperty? = nil, - choice5: WordCharProperty? = nil - ) -> Bool { - if value == choice1 { - return true - } - if value == choice2 { - return true - } - if let choice3 = choice3, value == choice3 { - return true - } - if let choice4 = choice4, value == choice4 { - return true - } - if let choice5 = choice5, value == choice5 { - return true - } - return false - } - - static func _isAHLetter(property: WordCharProperty?) -> Bool { - return _oneOf(value: property, choice1: .ALetter, choice2: .HebrewLetter) - } -} diff --git a/Sources/ShaftWeb/Utils/BrowserDetection.swift b/Sources/ShaftWeb/Utils/BrowserDetection.swift deleted file mode 100644 index 822b96b..0000000 --- a/Sources/ShaftWeb/Utils/BrowserDetection.swift +++ /dev/null @@ -1,266 +0,0 @@ -import JavaScriptKit - -/// The HTML engine used by the current browser. -enum BrowserEngine: String { - /// The engine that powers Chrome, Samsung Internet Browser, UC Browser, - /// Microsoft Edge, Opera, and others. - /// - /// Blink is assumed in case when a more precise browser engine wasn't - /// detected. - case blink - /// The engine that powers Safari. - case webkit - /// The engine that powers Firefox. - case firefox -} - -/// Operating system where the current browser runs. -/// -/// Taken from the navigator platform. -/// -enum OperatingSystem: String { - /// iOS: - case iOS - /// Android: - case android - /// Linux: - case linux - /// Windows: - case windows - /// MacOS: - case macOS - /// We were unable to detect the current operating system. - case unknown -} - -// List of Operating Systems we know to be working on laptops/desktops. -// -// These devices tend to behave differently on many core issues such as events, -// screen readers, input devices. -private let _desktopOperatingSystems: Set = [ - .macOS, - .linux, - .windows, -] - -/// The core Browser Detection functionality from the Flutter web engine. -class BrowserDetection { - private init() {} - - /// The singleton instance of the [BrowserDetection] class. - static let instance = BrowserDetection() - - /// Returns the User Agent of the current browser. - var userAgent: String { - return debugUserAgentOverride ?? _userAgent - } - - /// Override value for [userAgent]. - /// - /// Setting this to `null` uses the default [domWindow.navigator.userAgent]. - package var debugUserAgentOverride: String? - - // Lazily initialized current user agent. - private lazy var _userAgent: String = _detectUserAgent() - - private func _detectUserAgent() -> String { - return JSObject.global.navigator.userAgent.string! - } - - /// Returns the [BrowserEngine] used by the current browser. - /// - /// This is used to implement browser-specific behavior. - var browserEngine: BrowserEngine { - return debugBrowserEngineOverride ?? _browserEngine - } - - /// Override the value of [browserEngine]. - /// - /// Setting this to `null` lets [browserEngine] detect the browser that the - /// app is running on. - package var debugBrowserEngineOverride: BrowserEngine? - - // Lazily initialized current browser engine. - private lazy var _browserEngine: BrowserEngine = _detectBrowserEngine() - - private func _detectBrowserEngine() -> BrowserEngine { - let vendor = JSObject.global.navigator.vendor.string! - let agent = userAgent.lowercased() - return detectBrowserEngineByVendorAgent(vendor: vendor, agent: agent) - } - - /// Detects browser engine for a given vendor and agent string. - package func detectBrowserEngineByVendorAgent(vendor: String, agent: String) -> BrowserEngine { - if vendor == "Google Inc." { - return .blink - } else if vendor == "Apple Computer, Inc." { - return .webkit - } else if agent.contains("Edg/") { - // Chromium based Microsoft Edge has `Edg` in the user-agent. - // https://docs.microsoft.com/en-us/microsoft-edge/web-platform/user-agent-string - return .blink - } else if vendor == "" && agent.contains("firefox") { - // An empty string means firefox: - // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/vendor - return .firefox - } - - // Assume Blink otherwise, but issue a warning. - print( - "WARNING: failed to detect current browser engine. Assuming this is a Chromium-compatible browser." - ) - return .blink - } - - /// Returns the [OperatingSystem] the current browsers works on. - /// - /// This is used to implement operating system specific behavior such as - /// soft keyboards. - var operatingSystem: OperatingSystem { - return debugOperatingSystemOverride ?? _operatingSystem - } - - /// Override the value of [operatingSystem]. - /// - /// Setting this to `null` lets [operatingSystem] detect the real OS that the - /// app is running on. - /// - /// This is intended to be used for testing and debugging only. - package var debugOperatingSystemOverride: OperatingSystem? - - /// Lazily initialized current operating system. - private lazy var _operatingSystem: OperatingSystem = detectOperatingSystem() - - /// Detects operating system using platform and UA used for unit testing. - package func detectOperatingSystem( - overridePlatform: String? = nil, - overrideMaxTouchPoints: Int? = nil - ) -> OperatingSystem { - let platform = overridePlatform ?? JSObject.global.navigator.platform.string! - - if platform.starts(with: "Mac") { - // iDevices requesting a "desktop site" spoof their UA so it looks like a Mac. - // This checks if we're in a touch device, or on a real mac. - let maxTouchPoints = - overrideMaxTouchPoints ?? Int(JSObject.global.navigator.maxTouchPoints.number ?? 0) - if maxTouchPoints > 2 { - return .iOS - } - return .macOS - } else if platform.lowercased().contains("iphone") || platform.lowercased().contains("ipad") - || platform.lowercased().contains("ipod") - { - return .iOS - } else if userAgent.contains("Android") { - // The Android OS reports itself as "Linux armv8l" in - // [domWindow.navigator.platform]. So we have to check the user-agent to - // determine if the OS is Android or not. - return .android - } else if platform.starts(with: "Linux") { - return .linux - } else if platform.starts(with: "Win") { - return .windows - } else { - return .unknown - } - } - - /// A flag to check if the current [operatingSystem] is a laptop/desktop - /// operating system. - var isDesktop: Bool { - return _desktopOperatingSystems.contains(operatingSystem) - } - - /// A flag to check if the current browser is running on a mobile device. - /// - /// Flutter web considers "mobile" everything that not [isDesktop]. - var isMobile: Bool { - return !isDesktop - } - - /// Whether the current [browserEngine] is [BrowserEngine.blink] (Chrom(e|ium)). - var isChromium: Bool { - return browserEngine == .blink - } - - /// Whether the current [browserEngine] is [BrowserEngine.webkit] (Safari). - var isSafari: Bool { - return browserEngine == .webkit - } - - /// Whether the current [browserEngine] is [BrowserEngine.firefox]. - var isFirefox: Bool { - return browserEngine == .firefox - } - - /// Whether the current browser is Edge. - var isEdge: Bool { - return userAgent.contains("Edg/") - } - - /// Whether we are running from a wasm module compiled with dart2wasm. - // var isWasm: Bool { - // return !Bool.fromEnvironment("dart.library.html") - // } -} - -/// A short-hand accessor to the [BrowserDetection.instance] singleton. -let browser = BrowserDetection.instance - -/// A flag to check if the current browser is running on a laptop/desktop device. -var isDesktop: Bool { - return browser.isDesktop -} - -/// A flag to check if the current browser is running on a mobile device. -/// -/// Flutter web considers "mobile" everything that's not [isDesktop]. -var isMobile: Bool { - return browser.isMobile -} - -/// Whether the current browser is [BrowserEngine.blink] (Chrom(e|ium)). -var isChromium: Bool { - return browser.isChromium -} - -/// Whether the current browser is [BrowserEngine.webkit] (Safari). -var isSafari: Bool { - return browser.isSafari -} - -/// Whether the current browser is [BrowserEngine.firefox]. -var isFirefox: Bool { - return browser.isFirefox -} - -/// Whether the current browser is Edge. -var isEdge: Bool { - return browser.isEdge -} - -/// Whether we are running from a wasm module compiled with dart2wasm. -/// -/// Note: Currently the ffi library is available from dart2wasm but not dart2js -/// or dartdevc. -// var isWasm: Bool { -// return browser.isWasm -// } - -// Whether the detected `operatingSystem` is `OperatingSystem.iOs`. -private var isIOS: Bool { - return browser.operatingSystem == .iOS -} - -/// Whether the browser is running on macOS or iOS. -/// -/// - See [operatingSystem]. -/// - See [OperatingSystem]. -var isMacOrIOS: Bool { - return isIOS || browser.operatingSystem == .macOS -} - -/// Detect iOS 15. -var isIOS15: Bool { - return (isIOS && browser.userAgent.contains("OS 15_")) -} diff --git a/Sources/ShaftWeb/Utils/Dom.swift b/Sources/ShaftWeb/Utils/Dom.swift deleted file mode 100644 index ec6af78..0000000 --- a/Sources/ShaftWeb/Utils/Dom.swift +++ /dev/null @@ -1,12 +0,0 @@ -import JavaScriptKit - -func createDomHTMLDivElement() -> JSValue { - return JSObject.global.document.createElement("div") -} - -func createDomCanvasElement(width: Int, height: Int) -> JSValue { - var canvas = JSObject.global.document.createElement("canvas") - canvas.width = .number(Double(width)) - canvas.height = .number(Double(height)) - return canvas -} diff --git a/Sources/ShaftWeb/Utils/StyleManager.swift b/Sources/ShaftWeb/Utils/StyleManager.swift deleted file mode 100644 index dfebf0b..0000000 --- a/Sources/ShaftWeb/Utils/StyleManager.swift +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import JavaScriptKit -import Shaft - -/// Manages the CSS styles of the Flutter View. -class StyleManager { - static let defaultFontStyle = "normal" - static let defaultFontWeight = "normal" - static let defaultFontSize = Float(14.0) - static let defaultFontFamily = "sans-serif" - static let defaultCssFont = - "\(defaultFontStyle) \(defaultFontWeight) \(Int(defaultFontSize))px \(defaultFontFamily)" - - // static func attachGlobalStyles( - // node: DomNode, - // styleId: String, - // styleNonce: String?, - // cssSelectorPrefix: String - // ) { - // let styleElement = createDomHTMLStyleElement(styleNonce) - // styleElement.id = styleId - // // The style element must be appended to the DOM, or its `sheet` will be null later. - // node.appendChild(styleElement) - // applyGlobalCssRulesToSheet( - // styleElement, - // defaultCssFont: StyleManager.defaultCssFont, - // cssSelectorPrefix: cssSelectorPrefix - // ) - // } - - // static func styleSceneHost( - // _ sceneHost: DomElement, - // debugShowSemanticsNodes: Bool = false - // ) { - // assert(sceneHost.tagName.toLowerCase() == DomManager.sceneHostTagName.toLowerCase()) - // // Don't allow the scene to receive pointer events. - // sceneHost.style.pointerEvents = "none" - // // When debugging semantics, make the scene semi-transparent so that the - // // semantics tree is more prominent. - // if debugShowSemanticsNodes { - // sceneHost.style.opacity = "0.3" - // } - // } - - // static func styleSemanticsHost( - // _ semanticsHost: DomElement, - // _ devicePixelRatio: Double - // ) { - // assert(semanticsHost.tagName.toLowerCase() == DomManager.semanticsHostTagName.toLowerCase()) - // semanticsHost.style.position = "absolute" - // semanticsHost.style.transformOrigin = "0 0 0" - // scaleSemanticsHost(semanticsHost, devicePixelRatio) - // } - - // /// The framework specifies semantics in physical pixels, but CSS uses - // /// logical pixels. To compensate, an inverse scale is injected at the root - // /// level. - // static func scaleSemanticsHost( - // _ semanticsHost: DomElement, - // _ devicePixelRatio: Double - // ) { - // assert(semanticsHost.tagName.toLowerCase() == DomManager.semanticsHostTagName.toLowerCase()) - // semanticsHost.style.transform = "scale(\(1 / devicePixelRatio))" - // } - // } - - // /// Applies the required global CSS to an incoming [DomCSSStyleSheet] `sheet`. - // @visibleForTesting - // func applyGlobalCssRulesToSheet( - // _ styleElement: DomHTMLStyleElement, - // cssSelectorPrefix: String = "", - // defaultCssFont: String - // ) { - // styleElement.appendText( - // // Fixes #115216 by ensuring that our parameters only affect the flt-scene-host children. - // "\(cssSelectorPrefix) \(DomManager.sceneHostTagName) {" + " font: \(defaultCssFont);" + "}" - - // // This undoes browser's default painting and layout attributes of range - // // input, which is used in semantics. - // + "\(cssSelectorPrefix) flt-semantics input[type=range] {" + " appearance: none;" - // + " -webkit-appearance: none;" + " width: 100%;" + " position: absolute;" - // + " border: none;" + " top: 0;" + " right: 0;" + " bottom: 0;" + " left: 0;" + "}" - - // // The invisible semantic text field may have a visible cursor and selection - // // highlight. The following 2 CSS rules force everything to be transparent. - // + "\(cssSelectorPrefix) input::selection {" + " background-color: transparent;" + "}" - // + "\(cssSelectorPrefix) textarea::selection {" + " background-color: transparent;" - // + "}" + - - // "\(cssSelectorPrefix) flt-semantics input," - // + "\(cssSelectorPrefix) flt-semantics textarea," - // + "\(cssSelectorPrefix) flt-semantics [contentEditable=\"true\"] {" - // + " caret-color: transparent;" + "}" - - // // Hide placeholder text - // + "\(cssSelectorPrefix) .flt-text-editing::placeholder {" + " opacity: 0;" + "}" - - // // Hide outline when the flutter-view root element is focused. - // + "\(cssSelectorPrefix):focus {" + " outline: none;" + "}" - // ) - - // // By default on iOS, Safari would highlight the element that's being tapped - // // on using gray background. This CSS rule disables that. - // if isSafari { - // styleElement.appendText( - // "\(cssSelectorPrefix) * {" + " -webkit-tap-highlight-color: transparent;" + "}" + - - // "\(cssSelectorPrefix) flt-semantics input[type=range]::-webkit-slider-thumb {" - // + " -webkit-appearance: none;" + "}" - // ) - // } - - // if isFirefox { - // // For firefox set line-height, otherwise text at same font-size will - // // measure differently in ruler. - // // - // // - See: https://github.com/flutter/flutter/issues/44803 - // styleElement.appendText( - // "\(cssSelectorPrefix) flt-paragraph," + "\(cssSelectorPrefix) flt-span {" - // + " line-height: 100%;" + "}" - // ) - // } - - // // This CSS makes the autofill overlay transparent in order to prevent it - // // from overlaying on top of Flutter-rendered text inputs. - // // See: https://github.com/flutter/flutter/issues/118337. - // if browserHasAutofillOverlay() { - // styleElement.appendText( - // "\(cssSelectorPrefix) .transparentTextEditing:-webkit-autofill," - // + "\(cssSelectorPrefix) .transparentTextEditing:-webkit-autofill:hover," - // + "\(cssSelectorPrefix) .transparentTextEditing:-webkit-autofill:focus," - // + "\(cssSelectorPrefix) .transparentTextEditing:-webkit-autofill:active {" - // + " opacity: 0 !important;" + "}" - // ) - // } - - // // Removes password reveal icon for text inputs in Edge browsers. - // // Non-Edge browsers will crash trying to parse -ms-reveal CSS selector, - // // so we guard it behind an isEdge check. - // // Fixes: https://github.com/flutter/flutter/issues/83695 - // if isEdge { - // // We try-catch this, because in testing, we fake Edge via the UserAgent, - // // so the below will throw an exception (because only real Edge understands - // // the ::-ms-reveal pseudo-selector). - // do { - // styleElement.appendText( - // "\(cssSelectorPrefix) input::-ms-reveal {" + " display: none;" + "}" - // ) - // } catch let e as DomException { - // // Browsers that don't understand ::-ms-reveal throw a DOMException - // // of type SyntaxError. - // domWindow.console.warn(e) - // // Add a fake rule if our code failed because we're under testing - // assert( - // { - // styleElement.appendText( - // "\(cssSelectorPrefix) input.fallback-for-fakey-browser-in-ci {" - // + " display: none;" + "}" - // ) - // return true - // }() - // ) - // } - // } -} diff --git a/Sources/ShaftWeb/Utils/Util.swift b/Sources/ShaftWeb/Utils/Util.swift deleted file mode 100644 index eb75c6c..0000000 --- a/Sources/ShaftWeb/Utils/Util.swift +++ /dev/null @@ -1,57 +0,0 @@ -/// Create a font-family string appropriate for CSS. -/// -/// If the given [fontFamily] is a generic font-family, then just return it. -/// Otherwise, wrap the family name in quotes and add a fallback font family. -func canonicalizeFontFamily(_ fontFamilies: [String]?) -> String { - // return "\"\(fontFamily ?? "")\", \(fallbackFontFamily), sans-serif" - var result = "" - for fontFamily in fontFamilies ?? [] { - result += "\"\(fontFamily)\", " - } - result += fallbackFontFamily - result += ", sans-serif" - return result -} - -/// From: https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#Syntax -/// -/// Generic font families are a fallback mechanism, a means of preserving some -/// of the style sheet author's intent when none of the specified fonts are -/// available. Generic family names are keywords and must not be quoted. A -/// generic font family should be the last item in the list of font family -/// names. -let genericFontFamilies: Set = [ - "serif", - "sans-serif", - "monospace", - "cursive", - "fantasy", - "system-ui", - "math", - "emoji", - "fangsong", -] - -/// A default fallback font family in case an unloaded font has been requested. -/// -/// -apple-system targets San Francisco in Safari (on Mac OS X and iOS), -/// and it targets Neue Helvetica and Lucida Grande on older versions of -/// Mac OS X. It properly selects between San Francisco Text and -/// San Francisco Display depending on the text’s size. -/// -/// For iOS, default to -apple-system, where it should be available, otherwise -/// default to Arial. BlinkMacSystemFont is used for Chrome on iOS. -var fallbackFontFamily: String { - if isIOS15 { - // Remove the "-apple-system" fallback font because it causes a crash in - // iOS 15. - // - // See github issue: https://github.com/flutter/flutter/issues/90705 - // See webkit bug: https://bugs.webkit.org/show_bug.cgi?id=231686 - return "BlinkMacSystemFont" - } - if isMacOrIOS { - return "-apple-system, BlinkMacSystemFont" - } - return "Arial" -} diff --git a/Sources/WebDemo/main.swift b/Sources/WebDemo/main.swift deleted file mode 100644 index 5478d46..0000000 --- a/Sources/WebDemo/main.swift +++ /dev/null @@ -1,21 +0,0 @@ -import JavaScriptEventLoop -import JavaScriptKit -import Shaft -import ShaftWeb - -Shaft.backend = ShaftWebBackend(onCreateElement: { viewID in - let document = JSObject.global.document - return document.querySelector("canvas") -}) - -print("Hello, Web!") - -runApp( - Column(crossAxisAlignment: .start) { - Button { - print("Hello, Button!") - } child: { - Text("Hello World") - } - } -) From e937f19d32f717ce42db6284376020349357e098 Mon Sep 17 00:00:00 2001 From: tyxu Date: Sat, 27 Sep 2025 22:54:40 +0800 Subject: [PATCH 2/5] Uint64 --- Sources/Shaft/Core/KeyboardKey.swift | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/Shaft/Core/KeyboardKey.swift b/Sources/Shaft/Core/KeyboardKey.swift index 700837a..5b0ed85 100644 --- a/Sources/Shaft/Core/KeyboardKey.swift +++ b/Sources/Shaft/Core/KeyboardKey.swift @@ -3807,74 +3807,74 @@ extension LogicalKeyboardKey { /// Mask for the 32-bit value portion of the key code. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let valueMask = 0x000_ffff_ffff + private static let valueMask: UInt64 = 0x000_ffff_ffff /// Mask for the plane prefix portion of the key code. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let planeMask = 0x0ff_0000_0000 + private static let planeMask: UInt64 = 0x0ff_0000_0000 /// The plane value for keys which have a Unicode representation. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let unicodePlane = 0x000_0000_0000 + private static let unicodePlane: UInt64 = 0x000_0000_0000 /// The plane value for keys defined by Chromium and does not have a Unicode /// representation. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let unprintablePlane = 0x001_0000_0000 + private static let unprintablePlane: UInt64 = 0x001_0000_0000 /// The plane value for keys defined by Flutter. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let flutterPlane = 0x002_0000_0000 + private static let flutterPlane: UInt64 = 0x002_0000_0000 /// The platform plane with the lowest mask value, beyond which the keys are /// considered autogenerated. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let startOfPlatformPlanes = 0x011_0000_0000 + private static let startOfPlatformPlanes: UInt64 = 0x011_0000_0000 /// The plane value for the private keys defined by the Android embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let androidPlane = 0x011_0000_0000 + private static let androidPlane: UInt64 = 0x011_0000_0000 /// The plane value for the private keys defined by the Fuchsia embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let fuchsiaPlane = 0x012_0000_0000 + private static let fuchsiaPlane: UInt64 = 0x012_0000_0000 /// The plane value for the private keys defined by the iOS embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let iosPlane = 0x013_0000_0000 + private static let iosPlane: UInt64 = 0x013_0000_0000 /// The plane value for the private keys defined by the macOS embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let macosPlane = 0x014_0000_0000 + private static let macosPlane: UInt64 = 0x014_0000_0000 /// The plane value for the private keys defined by the Gtk embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let gtkPlane = 0x015_0000_0000 + private static let gtkPlane: UInt64 = 0x015_0000_0000 /// The plane value for the private keys defined by the Windows embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let windowsPlane = 0x016_0000_0000 + private static let windowsPlane: UInt64 = 0x016_0000_0000 /// The plane value for the private keys defined by the Web embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let webPlane = 0x017_0000_0000 + private static let webPlane: UInt64 = 0x017_0000_0000 /// The plane value for the private keys defined by the GLFW embedding. /// /// This is used by platform-specific code to generate Flutter key codes. - private static let glfwPlane = 0x018_0000_0000 + private static let glfwPlane: UInt64 = 0x018_0000_0000 /// A predefined map of key labels for non-printable keys private static let _keyLabels: [UInt64: String] = [ From 297b8e42b7d78ec88e0d7000ca6475f7fb7ae8c0 Mon Sep 17 00:00:00 2001 From: tyxu Date: Sat, 27 Sep 2025 22:54:45 +0800 Subject: [PATCH 3/5] Fetch --- Sources/Fetch/Fetch.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/Fetch/Fetch.swift b/Sources/Fetch/Fetch.swift index 87b6fa4..13ef9dc 100644 --- a/Sources/Fetch/Fetch.swift +++ b/Sources/Fetch/Fetch.swift @@ -2,17 +2,22 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking -#elseif os(wasi) - import JavaScriptKit +// #elseif canImport(JavaScriptKit) +// import JavaScriptKit #endif /// Fetches data from a URL. public func fetch(_ url: URL) async throws -> Data { - #if os(wasi) - let jsFetch = JSObject.global.fetch.function! - let response = try await JSPromise(jsFetch(url.absoluteString).object!)!.jsValue - let buffer = try await JSPromise(response.arrayBuffer().object!)!.jsValue - return Data() + #if canImport(JavaScriptKit) + // let jsFetch = JSObject.global.fetch.function! + // let response = try await JSPromise(jsFetch(url.absoluteString).object!)!.jsValue + // let buffer = try await JSPromise(response.arrayBuffer().object!)!.jsValue + // return Data() + throw NSError( + domain: "Fetch", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Fetch is not supported on JavaScriptKit"] + ) #else return try await URLSession.shared.data(from: url).0 #endif From b058b01a202832393dc99e88b05ece7c57de8949 Mon Sep 17 00:00:00 2001 From: tyxu Date: Sat, 27 Sep 2025 22:54:54 +0800 Subject: [PATCH 4/5] Update .gitignore to include .reference, *.artifactbundle, and *.zip --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e26272b..084fa75 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ DerivedData/ .history .cursor -.reference \ No newline at end of file +.reference + +*.artifactbundle +*.zip \ No newline at end of file From f6be7bd9b83397b8d9c23ba4844bfbf3666dd62d Mon Sep 17 00:00:00 2001 From: tyxu Date: Sun, 28 Sep 2025 15:26:53 +0800 Subject: [PATCH 5/5] Handle input connection close --- Sources/Shaft/Core/Backend.swift | 3 +++ Sources/Shaft/Services/TextInput.swift | 9 +++++++++ Sources/Shaft/Widgets/Text/EditableText.swift | 9 ++++++++- Sources/ShaftSDL3/SDLView.swift | 5 +++++ Tests/ShaftTests/TestUtils/WidgetTester.swift | 2 ++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Sources/Shaft/Core/Backend.swift b/Sources/Shaft/Core/Backend.swift index 03b572a..d63b93b 100644 --- a/Sources/Shaft/Core/Backend.swift +++ b/Sources/Shaft/Core/Backend.swift @@ -61,6 +61,9 @@ public protocol NativeView: AnyObject { /// and begin receiving these events. var onTextComposed: TextComposedCallback? { get set } + /// A callback that is invoked when the text input connection is closed. + var onTextInputClosed: VoidCallback? { get set } + /// Getting/Setting the title of the view if possible. var title: String { get set } diff --git a/Sources/Shaft/Services/TextInput.swift b/Sources/Shaft/Services/TextInput.swift index fa5b189..96a5476 100644 --- a/Sources/Shaft/Services/TextInput.swift +++ b/Sources/Shaft/Services/TextInput.swift @@ -10,6 +10,7 @@ public class TextInput { self.view = view view.onTextEditing = handleTextEditing view.onTextComposed = handleTextComposed + view.onTextInputClosed = handleTextInputClosed } public weak var view: NativeView! @@ -34,6 +35,11 @@ public class TextInput { currentConnection?.client?.onTextComposed(text: text) } + private func handleTextInputClosed() { + currentConnection?.client?.onTextInputClosed() + currentConnection = nil + } + fileprivate func setComposingRect(_ rect: Rect) { view.setComposingRect(rect) } @@ -75,6 +81,9 @@ public protocol TextInputClient: AnyObject { /// Called when the text has been composed and committed. func onTextComposed(text: String) + + /// Called when the text input connection is closed. + func onTextInputClosed() } /// An interface for interacting with a text input control. diff --git a/Sources/Shaft/Widgets/Text/EditableText.swift b/Sources/Shaft/Widgets/Text/EditableText.swift index 9f8b4f3..da6af2b 100644 --- a/Sources/Shaft/Widgets/Text/EditableText.swift +++ b/Sources/Shaft/Widgets/Text/EditableText.swift @@ -1296,6 +1296,13 @@ public final class EditableTextState: State, TextSelectionDelegate // no-op } + public func onTextInputClosed() { + if hasInputConnection { + textInputConnection = nil + widget.focusNode.unfocus() + } + } + /// Sends the current composing rect to the embedder's text input plugin. /// /// In cases where the composing rect hasn't been updated in the embedder due @@ -1752,7 +1759,7 @@ public final class EditableTextState: State, TextSelectionDelegate } private var textInputConnection: TextInputConnection? - private var hasInputConnection: Bool { textInputConnection != nil } + private var hasInputConnection: Bool { textInputConnection?.isActive ?? false } private func openOrCloseInputConnectionIfNeeded() { if hasFocus && widget.focusNode.consumeKeyboardToken() { diff --git a/Sources/ShaftSDL3/SDLView.swift b/Sources/ShaftSDL3/SDLView.swift index 105cc08..9d6e8d7 100644 --- a/Sources/ShaftSDL3/SDLView.swift +++ b/Sources/ShaftSDL3/SDLView.swift @@ -187,6 +187,7 @@ public class SDLView: NativeView { if SDL_StopTextInput(sdlWindow) { backend?.textEditingView = nil + onTextInputClosed?() } else { mark("SDL_StopTextInput failed") } @@ -246,6 +247,10 @@ public class SDLView: NativeView { /// events are received. public var onTextComposed: TextComposedCallback? + /// It's the backend's responsibility to call this method when the text input + /// connection is closed. + public var onTextInputClosed: VoidCallback? + fileprivate func handleEventSync(_ event: inout SDL_Event) -> Bool { switch SDL_EventType(event.type.cast()) { case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: diff --git a/Tests/ShaftTests/TestUtils/WidgetTester.swift b/Tests/ShaftTests/TestUtils/WidgetTester.swift index 9852184..982a4a3 100644 --- a/Tests/ShaftTests/TestUtils/WidgetTester.swift +++ b/Tests/ShaftTests/TestUtils/WidgetTester.swift @@ -491,6 +491,8 @@ class TestBackend: Backend { var onTextComposed: TextComposedCallback? + var onTextInputClosed: VoidCallback? + var onKeyEvent: KeyEventCallback? var targetPlatform: TargetPlatform? {