#lang debug racket (require quad/quad "struct.rkt" "param.rkt" "debug.rkt" "font.rkt" "string.rkt" "attrs.rkt" quad/base sugar/list pitfall racket/unsafe/ops) (provide (all-defined-out)) (define (hr-draw dq doc) (match-define (list left top) (quad-origin dq)) (match-define (list right bottom) (size dq)) (save doc) (translate doc left (+ top (/ bottom 2.0))) (move-to doc 0 0) (line-to doc right 0) (line-width doc 0.5) (stroke doc "black") (restore doc)) (define (make-hr-quad line-q) (quad-copy line-quad line-q [draw-start hr-draw])) (define q:line (make-quad #:type line-quad #:size (pt 0 default-line-height) #:from 'sw #:to 'nw #:printable #true #:tag 'line #:draw-start (if draw-debug-line? draw-debug void))) (define (render-hyphen qs ending-q) ;; naive handling of soft hyphen: ;; if soft hyphen cause the break, then append a printing hyphen to the end of the run. ;; this assumes that there is room for the hyphen on the line ;; and does not take into account hyphen-break transformations found in other languages. ;; However we do want the hyphen joined into the string so the final shaping / positioning is correct ;; for instance, kerning between last letter and hyphen. (match (and ending-q (equal? (quad-elems ending-q) '("\u00AD")) qs) [(list head ... last-q) (define str (car (quad-elems last-q))) (define str+hyphen (string-append str "-")) (append head (list (quad-update! last-q [elems (list str+hyphen)] [size (make-size-promise-for-string last-q str+hyphen)])))] [_ qs])) (define (space-quad? q) (equal? (quad-elems q) (list " "))) (define (hang-punctuation nonspacess) (match nonspacess [(list sublists ... (list prev-qs ... last-q)) #:when (pair? (quad-elems last-q)) (match (regexp-match #rx"[.,:;’-]$" (car (quad-elems last-q))) [#false nonspacess] [last-char-str (define hanger-q (quad-copy string-quad last-q [elems null] [size (let ([p (make-size-promise-for-string last-q (car last-char-str))]) (delay (match-define (list x y) (force p)) (pt (- x) y)))])) (define last-sublist (append prev-qs (list last-q hanger-q))) (append sublists (list last-sublist))])] [_ nonspacess])) (define (sum-sum-x qss) (for/sum ([qs (in-list qss)]) (sum-x qs))) (define (tracking-adjustment q) (match q [(? string-quad?) (/ (quad-ref q :font-tracking 0) 2.0)] [_ 0])) (define (line-height-from-qs qs) ;; line height is the max 'line-height value or the natural height of q:line (match (filter-map (λ (q) (or (quad-ref q :line-height) (pt-y (size q)))) qs) [(? null?) #false] [line-heights (apply max line-heights)])) (define (fill-line-wrap all-qs line-prototype last-line-in-paragraph?) ;; happens during the finish of a line wrap, before consolidation of runs (unless (pair? all-qs) (raise-argument-error 'fill-line-wrap "nonempty list of quads" all-qs)) ;; remove anchored quads because they don't affect line layout (define-values (absolute-qs qs) (partition (λ (q) (quad-ref q :parent)) all-qs)) (match qs [(? null?) absolute-qs] [(and (cons q-first other-qs) (list _ ... q-last)) (define align-value (quad-ref q-first :line-align "left")) ;; words may still be in hyphenated fragments ;; (though soft hyphens would have been removed) ;; so group them (but no need to consolidate — that happens elsewhere) (define-values (spacess nonspacess) (partition* space-quad? qs)) (match (length nonspacess) [1 #:when (equal? align-value "justify") qs] ; can't justify single word [nonspacess-count (match-define (list line-prototype-width line-prototype-height) (quad-size line-prototype)) (define hung-nonspacess (hang-punctuation nonspacess)) (define left-tracking-adjustment (tracking-adjustment q-first)) (define right-tracking-adjustment (tracking-adjustment q-last)) (define nonspace-total-width (- (sum-sum-x hung-nonspacess) left-tracking-adjustment right-tracking-adjustment)) (define space-total-width (sum-sum-x spacess)) (define empty-hspace (- line-prototype-width (quad-ref q-first :inset-left 0) nonspace-total-width (quad-ref q-first :inset-right 0))) (define (make-left-edge-filler [width 0]) (make-quad #:type filler-quad #:tag 'line-filler #:from-parent (quad-from-parent q-first) #:from 'bo #:to 'bi #:shift (pt (- left-tracking-adjustment) 0) #:size (pt width 0) #:attrs (quad-attrs q-first))) (cond [(or (and (equal? align-value "justify") (or (not last-line-in-paragraph?) ;; don't justify the last line in a paragraph ;; unless empty space is less than 17% of width (an arbitrary visual threshold) (< (/ empty-hspace line-prototype-width 1.0) .17))) (let ([line-overfull? (negative? (- empty-hspace space-total-width))]) ;; force justification upon overfull lines, ;; which amounts to shrinking the word spaces till the line fits (and line-overfull? (> nonspacess-count 1)))) (define justified-space-width (/ empty-hspace (sub1 nonspacess-count))) (define justified-spacer (make-quad #:from 'bo #:to 'bi #:draw-end q:string-draw-end #:size (pt justified-space-width (or (line-height-from-qs (append* hung-nonspacess)) line-prototype-height)))) (cons (make-left-edge-filler) (apply append (add-between hung-nonspacess (list justified-spacer))))] [else (define space-multiplier (match align-value ["center" 0.5] ;; fill inner & outer as if they were right, ;; they will be corrected later, when pagination is known. [(or "right" "inner" "outer") 1] ;; "left" and "justify" are handled here [_ 0])) ;; subtact space-width because that appears between words ;; we only care about redistributing the space on the ends (define end-hspace (- empty-hspace space-total-width)) ;; make filler a leading quad, not a parent / grouping quad, ;; so that elements can still be reached by consolidate-runs (list* (make-left-edge-filler (* end-hspace space-multiplier)) (quad-update! q-first [from-parent #f]) ;; ok to put back absolute quads at end, because it doesn't affect their layout (append other-qs absolute-qs))])])])) (define (make-paragraph-spacer maybe-first-line-q key default-val) (define arbitrary-width 20) (make-quad #:type line-spacer-quad #:size (pt arbitrary-width (cond [(and maybe-first-line-q (quad-ref maybe-first-line-q key))] [else default-val])) #:from 'sw #:to 'nw #:printable only-prints-in-middle #:draw-start (if (draw-debug-line?) draw-debug void))) (define ((line-wrap-finish line-prototype-q default-block-id) wrap-qs q-before q-after idx) ;; we curry line-q so that the wrap size can be communicated to this operation ;; remove unused soft hyphens so they don't affect final shaping (define wrap-qs-printing (for/list ([wq (in-list wrap-qs)] #:unless (equal? (quad-elems wq) '("\u00AD"))) wq)) (define new-lines (cond [(empty? wrap-qs-printing) null] [(hr-break-quad? q-after) (list (make-hr-quad line-prototype-q))] [else ;; render hyphen first so that all printable characters are available for size-dependent ops. (define pcs-with-hyphen (render-hyphen wrap-qs-printing q-after)) ;; fill wrap so that consolidate-runs works properly ;; (justified lines won't be totally consolidated) (define last-line-in-paragraph? (not q-after)) (define pcs (fill-line-wrap pcs-with-hyphen line-prototype-q last-line-in-paragraph?)) (match (consolidate-runs pcs) [(and (cons elem-first _) elems) (match-define (list line-width line-height) (quad-size line-prototype-q)) (list (quad-copy line-quad line-prototype-q ;; move block attrs up, so they are visible in col wrap [attrs (let ([h (copy-block-attrs (quad-attrs elem-first) (hash-copy (quad-attrs line-prototype-q)))]) ;; we want every group of lines in a paragraph to have a block id ;; so that it will be wrapped as a block later. ;; we only set this if there is no value for :display. (hash-ref! h :display default-block-id) h)] ;; line width is static [size (pt line-width (or (line-height-from-qs pcs) line-height))] ;; handle list indexes. drop new quad into line to hold list index ;; could also use this for line numbers [elems ;; we assume here that a list item has already had extra inset-left ;; with room for a bullet ;; which we just insert at the front. ;; this is safe because line has already been filled. (append ;; only put bullet into line if we're at the first line of the list item (match (and (eq? idx 1) (quad-ref elem-first :list-index)) [#false null] [bullet (define bq (quad-copy string-quad q:string ;; copy q:string to get draw routine ;; borrow attrs from elem [attrs (quad-attrs elem-first)] ;; use bullet as elems [elems (list (if (number? bullet) (format "~a." bullet) bullet))] ;; size doesn't matter because nothing refers to this quad ;; just for debugging box [size (pt 15 (pt-y (size line-prototype-q)))])) (from-parent (list bq) 'sw)]) (from-parent (match (quad-ref elem-first :inset-left 0) [0 elems] [inset-val (cons (make-quad #:draw-end q:string-draw-end #:to 'sw #:size (pt inset-val 5) #:type offsetter-quad) elems)]) 'sw))]))] [_ null])])) (define maybe-first-line (and (pair? new-lines) (car new-lines))) (append (match q-before [#false (list (make-paragraph-spacer maybe-first-line :space-before 0))] ; paragraph break [_ null]) new-lines (match q-after [(? column-break-quad? column-break) (list column-break)] ; hard column (or section or page) break [#false (define default-paragraph-spacing (* default-line-height 0.6)) (list (make-paragraph-spacer maybe-first-line :space-after default-paragraph-spacing))] ; paragraph break [_ null]))) ; hard line break (define softies (map string '(#\space #\- #\u00AD))) (define (soft-break-for-line? q) (and (pair? (quad-elems q)) (member (unsafe-car (quad-elems q)) softies))) (define (permitted-justify-overfill q) (match (quad-ref q :line-align) ;; allow justified lines to go wider, ;; and then fill-wrap will tighten thes word spaces ;; this makes justified paragraphs more even, becuase ;; some lines are a little tight, as opposed to all of them being loose ;; this has to be based on a certain quad, not set globally for the line-wrap operation, ;; because different lines might have different alignment settings. ["justify" 1.025] [_ 1])) (define (line-wrap qs wrap-size [debug #false]) (unless (positive? wrap-size) (raise-argument-error 'line-wrap "positive number" wrap-size)) (match qs [(cons q _) ;; group lines into sublists separated by para-breaks, but then omit the para-breaks themselves ;; because they've served their purpose (leave the others, to be expressed later) ;; however, leave line-breaks in, because they will be handled by wrap. (define para-qss (let loop ([qs qs][acc null]) (match qs [(? null?) (reverse acc)] [(cons (? para-break-quad?) rest) (loop rest acc)] [(cons (? column-break-quad? bq) rest) (loop rest (cons bq acc))] [(list* (and (not (? para-break-quad?)) nbqs) ... rest) (loop rest (cons nbqs acc))]))) (define res (apply append (for/list ([para-qs (in-list para-qss)]) (define block-id (gensym)) (match para-qs [(? break-quad? bq) (list bq)] [(cons pq _) (define line-q-for-this-paragraph (quad-copy line-quad q:line [size (pt wrap-size (quad-ref pq :line-height default-line-height))])) (wrap para-qs (* (- wrap-size (quad-ref pq :inset-left 0) (quad-ref pq :inset-right 0)) (permitted-justify-overfill pq)) debug ;; during wrap, anchored qs are treated as having distance 0 ;; so they can staty in right place, so that relative queries will work. ;; but they won't affect where lines break #:distance (λ (q last-dist wrap-qs) (+ last-dist (cond [(quad-ref q :parent) 0] [(printable? q) (distance q)] [else 0]))) #:nicely (match (or (current-line-wrap) (quad-ref pq :line-wrap)) [(or "best" "kp") #true] [_ #false]) #:hard-break line-break-quad? #:soft-break soft-break-for-line? #:finish-wrap (line-wrap-finish line-q-for-this-paragraph block-id))])))) res] [_ null]))