FazBrowse GitHub Viewer
|
Trending
|
URL:
|
Home
Tools:
[Download Repo ZIP]
[View Raw Code]
[Original HTTPS Page]
plotdevice/plotdevice/gfx/text.py at main · plotdevice/plotdevice · GitHub
plotdevice
plotdevice
Repository navigation
Code
Issues
10
(10)
Pull requests
4
(4)
Discussions
Actions
Projects
Security and quality
Insights
Expand file tree
Breadcrumbs
plotdevice
/
plotdevice
/
gfx
/
text.py
Copy path
More file actions
More file actions
Latest commit
History
History
History
929 lines (770 loc) · 37.7 KB
Breadcrumbs
plotdevice
/
plotdevice
/
gfx
/
text.py
Copy path
File metadata and controls
929 lines (770 loc) · 37.7 KB
Raw
Copy raw file
Download raw file
Open symbols panel
Edit and raw actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
# encoding: utf-8
import
re
import
sys
from
unicodedata
import
normalize
from
collections
import
namedtuple
from
..
lib
.
cocoa
import
*
from
plotdevice
import
DeviceError
from
.
typography
import
*
from
.
geometry
import
Transform
,
Region
,
Size
,
Point
,
Pair
from
.
colors
import
Color
from
.
bezier
import
Bezier
from
.
atoms
import
TransformMixin
,
ColorMixin
,
EffectsMixin
,
StyleMixin
,
FrameMixin
,
Grob
from
..
util
import
_copy_attrs
,
trim_zeroes
,
numlike
,
ordered
,
XMLParser
,
Element
,
read
from
..
lib
import
foundry
from
.
import
_ns_context
_ctx
=
None
__all__
=
(
"Text"
,
"LEFT"
,
"RIGHT"
,
"CENTER"
,
"JUSTIFY"
,)
# text alignments
LEFT
=
"left"
RIGHT
=
"right"
CENTER
=
"center"
JUSTIFY
=
"justify"
_TEXT
=
dict
(
left
=
NSLeftTextAlignment
,
right
=
NSRightTextAlignment
,
center
=
NSCenterTextAlignment
,
justify
=
NSJustifiedTextAlignment
)
class
Text
(
EffectsMixin
,
TransformMixin
,
FrameMixin
,
StyleMixin
,
Grob
):
# from TransformMixin: transform transformmode translate() rotate() scale() skew() reset()
# from EffectsMixin: alpha blend shadow
# from FrameMixin: x y width height
# from StyleMixin: stylesheet fill _parse_style()
stateAttrs
=
(
'_nodes'
, )
opts
=
(
'str'
,
'xml'
,
'src'
)
def
__init__
(
self
,
*
args
,
**
kwargs
):
# assemble the NSMachinery
self
.
_engine
=
NSLayoutManager
.
alloc
().
init
()
self
.
_engine
.
setUsesScreenFonts_
(
False
)
self
.
_engine
.
setUsesFontLeading_
(
False
)
self
.
_store
=
NSTextStorage
.
alloc
().
init
()
self
.
_store
.
addLayoutManager_
(
self
.
_engine
)
if
args
and
isinstance
(
args
[
0
],
Text
):
# create a parallel set of nstext objects when copying an existing Text
# then bail out immediately (ignoring any other args)
orig
=
args
[
0
]
self
.
inherit
(
orig
)
self
.
_blocks
=
[
TextBlock
(
self
)
for
f
in
orig
.
_blocks
]
for
src
,
dst
in
zip
(
orig
.
_blocks
,
self
.
_blocks
):
dst
.
offset
,
dst
.
size
=
src
.
offset
,
src
.
size
self
.
_store
.
appendAttributedString_
(
orig
.
_store
)
return
# let the various mixins have a crack at the kwargs
super
(
Text
,
self
).
__init__
(
**
kwargs
)
# create a text block to manage layout and glyph-drawing
self
.
_blocks
=
[
TextBlock
(
self
)]
# maintain a lookup table of nodes within xml input
self
.
_nodes
=
{}
# use the first positional arg if no xml/str/src kwarg was passed
if
not
any
(
o
in
kwargs
for
o
in
self
.
opts
)
and
len
(
args
):
kwargs
[
'str'
],
args
=
str
(
args
[
0
]),
args
[
1
:]
# merge in any numlike positional args to define bounds
if
args
:
try
:
self
.
_frame
.
_parse
(
args
)
except
:
raise
DeviceError
(
"Expected an x/y point (and optional width/height) but got %r"
%
(
args
,))
# fontify the str/xml/src arg and store it in the TextBlock
self
.
append
(
**
{
k
:
v
for
k
,
v
in
kwargs
.
items
()
if
k
in
self
.
opts
})
def
__repr__
(
self
):
total
=
len
(
self
.
text
)
displayed
=
sum
(
self
.
blocks
[
-
1
].
_chars
)
msg
=
'chars=%i'
%
displayed
if
displayed
<
total
:
msg
+=
' overflow=%i'
%
(
total
-
displayed
)
if
self
.
blocks
[
1
:]:
msg
+=
' blocks=%i'
%
len
(
self
.
blocks
)
if
hasattr
(
self
,
'folio'
):
msg
+=
' idx=%i folio=%i'
%
(
self
.
idx
,
self
.
folio
)
return
"Text(%s)"
%
msg
def
append
(
self
,
txt
=
None
,
**
kwargs
):
"""Add a string to the end of the text run (with optional styling)
Usage:
txt.append(str, **kwargs) # add the string using included styling kwargs
txt.append(str="", **kwargs) # equivalent to first usage
txt.append(xml="", **kwargs) # parses xml for styling before rendering
txt.append(src="", **kwargs) # reads from the contents of a file or url
Keyword Args:
Accepts the same keyword arguments as the text() command. For any styling
parameters that are omitted the appended string will inherit the style of
the Text object it's being added to.
"""
is_xml
=
'xml'
in
kwargs
txt
=
kwargs
.
pop
(
'xml'
,
kwargs
.
pop
(
'str'
,
txt
))
src
=
kwargs
.
pop
(
'src'
,
None
)
StyleMixin
.
validate
(
kwargs
)
attrib_txt
=
None
if
src
is
not
None
:
# fetch the url or file's contents as unicode
txt
=
read
(
src
,
format
=
'txt'
)
is_xml
=
src
.
lower
().
endswith
(
'.xml'
)
# try using the nsmagic parsing of HTML/RTF to build an attributed string
if
not
is_xml
:
txt
=
normalize
(
"NFC"
,
txt
)
txt_bytes
=
txt
.
encode
(
'utf-8'
)
txt_opts
=
{
'CharacterEncoding'
:
NSUTF8StringEncoding
}
txt
,
info
,
err
=
NSMutableAttributedString
.
alloc
().
initWithData_options_documentAttributes_error_
(
NSData
.
dataWithBytes_length_
(
txt_bytes
,
len
(
txt_bytes
)),
txt_opts
,
None
,
None
)
# if the data got unpacked into anything more interesting than plain text,
# preserve its styling. otherwise fall through and style the txt val
if
re
.
search
(
r'(html|rtf)$'
,
info
.
get
(
'UTI'
)):
attrib_txt
=
txt
if
txt
is
not
None
and
not
attrib_txt
:
# convert non-textual `str` args to strings
if
not
isinstance
(
txt
,
str
)
and
not
is_xml
:
txt
=
repr
(
txt
)
txt
=
normalize
(
"NFC"
,
txt
)
# use the inherited baseline style but allow one-off overrides from kwargs
merged_style
=
self
.
_font
.
_spec
merged_style
.
update
(
fill
=
self
.
_fillcolor
)
merged_style
.
update
(
self
.
_parse_style
(
**
kwargs
))
# if the text is xml, parse it an overlay any stylesheet entries that map to
# its tag names. otherwise apply the merged style to the entire string
if
is_xml
:
# find any tagged regions that need styling
parser
=
XMLParser
(
txt
,
offset
=
len
(
self
.
text
))
# update our internal lookup table of nodes
for
tag
,
elts
in
parser
.
nodes
.
items
():
old_elts
=
self
.
_nodes
.
get
(
tag
, [])
self
.
_nodes
[
tag
]
=
old_elts
+
elts
# start building the display-string (with all the tags now removed)
attrib_txt
=
NSMutableAttributedString
.
alloc
().
initWithString_
(
parser
.
text
)
# generate the proper `ns' font attrs for each unique cascade of xml tags
attrs
=
{
seq
:
self
.
_fontify
(
merged_style
,
*
seq
)
for
seq
in
sorted
(
parser
.
regions
)}
# apply the attributes to the runs found by the parser
for
cascade
,
runs
in
parser
.
regions
.
items
():
style
=
attrs
[
cascade
]
for
rng
in
runs
:
attrib_txt
.
setAttributes_range_
(
style
,
rng
)
else
:
# don't parse as xml, just apply the current font(), align(), and fill()
attrs
=
self
.
_fontify
(
merged_style
)
attrib_txt
=
NSMutableAttributedString
.
alloc
().
initWithString_attributes_
(
txt
,
attrs
)
# ensure the very-first character of a Text is indented flush left. also watch for
# double-newlines at the edge of the existing string and the appended chars. grafs
# can suppress their indentation with a \b (a.k.a. \x08) at the beginning of the
# line and un-indented lead-grafs can force indentation by beginning with \t
pre_txt
=
self
.
_store
.
string
()
if
not
pre_txt
or
re
.
search
(
r'\n[\n\x08]$'
,
pre_txt
):
Text
.
_dedent
(
attrib_txt
)
elif
pre_txt
.
endswith
(
'
\n
'
):
if
re
.
match
(
r'\n[^\n]'
,
attrib_txt
.
string
()):
Text
.
_dedent
(
attrib_txt
,
1
)
elif
re
.
match
(
r'\x08'
,
attrib_txt
.
string
()):
Text
.
_dedent
(
attrib_txt
)
# ensure that any paragraph with more than one leading newline is indented flush-left
# (and let `\n\b` override auto-indentation)
for
m
in
re
.
finditer
(
r'\n\x08|\n\n+[^\n]'
,
attrib_txt
.
string
()):
Text
.
_dedent
(
attrib_txt
,
m
.
end
()
-
1
)
if
attrib_txt
:
# let the typesetter deal with the new substring
self
.
_store
.
beginEditing
()
self
.
_store
.
appendAttributedString_
(
attrib_txt
)
self
.
_store
.
endEditing
()
self
.
_resized
()
### NSAttributedString de/manglers ###
def
_fontify
(
self
,
defaults
,
*
styles
):
"""Merge the named-styles and defaults in order and return nsattibutedstring attrs"""
# use the inherited context settings as a baseline spec
spec
=
dict
(
defaults
)
# layer the styles to generate a final font and color
for
tag
in
styles
:
spec
.
update
(
self
.
stylesheet
.
_styles
.
get
(
tag
,{}))
# assign a font and color based on the coalesced spec
font
=
Font
({
k
:
v
for
k
,
v
in
spec
.
items
()
if
k
in
Stylesheet
.
kwargs
})
color
=
Color
(
spec
.
pop
(
'fill'
)).
copy
()
# factor the relevant attrs into a paragraph style
graf
=
NSMutableParagraphStyle
.
alloc
().
init
()
graf
.
setLineBreakMode_
(
NSLineBreakByWordWrapping
)
graf
.
setAlignment_
(
_TEXT
[
spec
[
'align'
]])
graf
.
setHyphenationFactor_
(
spec
[
'hyphenate'
])
eps
=
sys
.
float_info
.
epsilon
# force the typesetter to deal with real leading rather than `lineheight'
face_height
=
eps
+
font
.
size
*
(
font
.
_face
.
ascent
-
font
.
_face
.
descent
)
/
1000.0
graf
.
setLineHeightMultiple_
(
spec
[
'leading'
]
*
font
.
size
/
face_height
)
graf
.
setMaximumLineHeight_
(
font
.
size
*
spec
[
'leading'
]
+
eps
)
# handle indentation, horizontal margins, and vertical graf spacing
indent
=
font
.
size
*
spec
[
'indent'
]
tabs
=
abs
(
indent
or
font
.
size
)
head
,
tail
=
map
(
self
.
_to_px
,
spec
[
'margin'
])
top
,
bot
=
[
font
.
size
*
font
.
leading
*
d
for
d
in
spec
[
'spacing'
]]
graf
.
setTabStops_
([])
graf
.
setDefaultTabInterval_
(
tabs
)
graf
.
setParagraphSpacingBefore_
(
top
)
graf
.
setParagraphSpacing_
(
bot
)
graf
.
setTailIndent_
(
-
tail
)
if
indent
>
0
:
graf
.
setFirstLineHeadIndent_
(
indent
+
head
)
graf
.
setHeadIndent_
(
head
)
else
:
graf
.
setFirstLineHeadIndent_
(
head
)
graf
.
setHeadIndent_
(
abs
(
indent
)
+
head
)
if
not
spec
[
'tracking'
]:
# None means `kerning off entirely', 0 means `default letterspacing'
kern
=
0
if
spec
[
'tracking'
]
is
None
else
eps
else
:
# convert the em-based tracking val to a point-based kerning val
kern
=
(
spec
[
'tracking'
]
*
font
.
size
)
/
1000.0
# build the dict of features for this combination of styles
return
dict
(
NSFont
=
font
.
_nsFont
,
PDColor
=
color
,
NSParagraphStyle
=
graf
,
NSKern
=
kern
)
def
_colorize
(
self
):
"""Updates the TextStorage, rewriting Colors as rgb or cmyk NSColors based on the current output mode"""
at
=
0
end
=
self
.
_store
.
length
()
while
at
<
end
:
clr
,
rng
=
self
.
_store
.
attribute_atIndex_effectiveRange_
(
'PDColor'
,
at
,
None
)
self
.
_store
.
addAttribute_value_range_
(
'NSColor'
,
clr
.
nsColor
,
rng
)
at
+=
rng
.
length
@
classmethod
def
_dedent
(
cls
,
attrib_txt
,
idx
=
0
,
inherit
=
False
):
"""Removes first-line paragraph indentation of at the given attributed-string index.
Sets the first-line indent equal to the subsequent-line's value (unless the first line
is outdented; in which case leave it alone).
Passing inherit=True will override this logic and set the first-indent equal to the
subsequent-indent without regard for its indent/outdent status.
Note that this method *modifies* the attrib_txt reference rather than returning a value.
"""
if
not
attrib_txt
.
length
():
return
attrib_txt
.
beginEditing
()
old_graf
,
_
=
attrib_txt
.
attribute_atIndex_effectiveRange_
(
"NSParagraphStyle"
,
idx
,
None
);
graf
=
old_graf
.
mutableCopy
()
first
,
rest
=
graf
.
firstLineHeadIndent
(),
graf
.
headIndent
()
if
first
>
rest
or
inherit
:
# leave negative-indentations alone
graf
.
setFirstLineHeadIndent_
(
rest
)
attrib_txt
.
addAttribute_value_range_
(
"NSParagraphStyle"
,
graf
, (
idx
,
1
))
attrib_txt
.
endEditing
()
### flowing text into new Text objects or subsidiary TextBlocks ###
def
overleaf
(
self
):
"""Returns a Text object containing any characters that did not fit within this object's bounds.
If the entire string fits within the current object, returns None."""
seen
=
""
.
join
(
getattr
(
f
,
'text'
)
for
f
in
self
.
_blocks
)
full
=
self
.
text
if
full
not
in
seen
:
next_pg
=
self
.
copy
()
# delete the first page's-worth of chars and update indices
nc
=
len
(
seen
)
next_pg
.
_store
.
beginEditing
()
next_pg
.
_store
.
deleteCharactersInRange_
([
0
,
nc
])
next_pg
.
_store
.
endEditing
()
nodes
=
{}
for
tag
,
elts
in
self
.
_nodes
.
items
():
nodes
[
tag
]
=
[
e
.
_replace
(
start
=
e
.
start
-
nc
,
end
=
e
.
end
-
nc
)
for
e
in
elts
if
e
.
end
-
nc
>
0
]
next_pg
.
_nodes
=
nodes
# if the page-break is in the middle of a paragraph, preserve the first character's initial
# indentation (since otherwise it'll be treated as a `first' line of a new paragraph)
if
not
seen
.
endswith
(
'
\n
'
):
Text
.
_dedent
(
next_pg
.
_store
,
inherit
=
True
)
return
next_pg
def
flow
(
self
,
columns
=
all
,
layout
=
None
):
"""Add as many text blocks as necessary to fully lay out the string
When called without arguments, returns a generator that you can iterate through to
set the position and size of each block in turn (starting with the second). Each block
is initialized with the same dimensions as the previous block in the sequence.
The optional `columns` argument allows you to specify the maximum number of blocks
you'd like to have at the end of the process. Note that this count *includes* the
Text object's original block, so you'll iterate over columns-1 blocks during the flow.
The optional `layout` argument can be a reference to a function which takes a single
TextBlock argument. If present, your layout function will be called once for each
block added to the stream.
"""
# sanity-check the columns arg
columns
=
1e4
if
columns
is
all
else
int
(
columns
or
1
)
if
columns
<=
1
:
# no iteration necessary in the single-block case
return
list
(
self
.
_reflow
(
columns
))
if
not
layout
:
return
self
.
_reflow
(
columns
)
# return the generator for iteration
map
(
layout
,
self
.
_reflow
(
columns
))
# apply the layout function to each block in sequence
def
_reflow
(
self
,
count
):
# wipe out any previously set blocks then keep adding new ones until
# the glyphs are fully laid out
while
self
.
_blocks
[
1
:]:
self
.
_blocks
.
pop
().
_eject
()
block
=
self
.
_blocks
[
0
]
while
len
(
self
.
_blocks
)
<
count
and
sum
(
block
.
_glyphs
)
<
self
.
_engine
.
numberOfGlyphs
():
block
=
TextBlock
(
block
)
self
.
_blocks
.
append
(
block
)
yield
block
### Layout geometry ###
@
property
def
frame
(
self
):
"""Returns the bounding box in which the text will be laid out"""
box
=
Region
()
for
block
in
self
.
_blocks
:
box
=
box
.
union
(
block
.
frame
)
return
box
@
property
def
bounds
(
self
):
"""Returns the size & position of the actual text (typically a subset of the bounds)"""
box
=
Region
()
for
block
in
self
.
_blocks
:
box
=
box
.
union
(
block
.
bounds
)
return
box
@
property
def
metrics
(
self
):
"""Returns the size of the actual text (shorthand for Text.bounds.size)"""
return
self
.
bounds
.
size
def
_get_baseline
(
self
):
"""Returns the Text object's baseline `origin point'"""
return
Point
(
self
.
x
,
self
.
y
)
def
_set_baseline
(
self
,
baseline
):
self
.
x
,
self
.
y
=
baseline
baseline
=
property
(
_get_baseline
,
_set_baseline
)
### Searching for substrings (and their layout geometry) ###
def
__getitem__
(
self
,
index
):
"""Subscripting a Text using indices into its .text string returns a TextFragment"""
match
=
TextFragment
(
self
)
if
isinstance
(
index
,
slice
):
match
.
start
,
match
.
end
,
_
=
index
.
indices
(
len
(
self
))
else
:
if
index
<
0
:
index
+=
len
(
self
)
if
not
0
<=
index
<
len
(
self
):
raise
IndexError
match
.
start
,
match
.
end
=
index
,
index
+
1
return
match
def
__len__
(
self
):
return
len
(
self
.
text
)
def
find
(
self
,
regex
,
matches
=
0
):
"""Find all matching portions of the text string using regular expressions
Syntax:
txt.find(re.compile(r'...', re.I)) # match a regex object
txt.find(r'antidisest.*?ism') # match a pattern string
txt.find(r'foo (.*?) baz') # match the pattern and capture a sub-group
txt.find(r'the', 10) # find the first 10 occurrences of `the'
Args:
`regex` can be a pattern string or a regex object. Pattern strings without
any uppercase characters will be case-insensitively matched. Patterns with
mixed case will be case-sensitive. In addition, the re.DOTALL flag will be
passed by default (meaning r'.' will match any character, including newlines).
Compiled regexes can define their own flags.
`matches` optionally set the maximum number of results to be returned. If
omitted, find() will return a TextFragment object for every match that's
visible in one of the Text object's TextBlocks. Matches that lie in the
overflow beyond the Text's bounds can be included however: pass the `all`
keyword as the `matches` arg.
Returns:
a list of TextFragment objects
"""
if
isinstance
(
regex
,
str
):
flags
=
(
re
.
I
|
re
.
S
)
if
regex
.
lower
()
==
regex
else
(
re
.
S
)
regex
=
re
.
compile
(
regex
,
flags
)
if
not
hasattr
(
regex
,
'pattern'
):
nonregex
=
"Text.find() must be called with an re.compile'd pattern object or a regular expression string"
raise
DeviceError
(
nonregex
)
return
self
.
_seek
(
regex
.
finditer
(
self
.
text
),
matches
)
def
select
(
self
,
tag_name
,
matches
=
0
):
"""Find all matching portions of the text string using regular expressions
Syntax:
txt.select('em')) # find all visible `em' tag regions
txt.select('p', all) # find every `p' tag, even in the overflow
Args:
`tag_name` is a string that corresponds to one of the element names you
used when calling text() or txt.append() with an `xml` argument. Note that
any tag-attributes you defined in the xml will be available through the
resulting TextFragment object's `attrs` property.
`matches` optionally set the maximum number of results to be returned. If
omitted, select() will return a TextFragment object for every match that's
visible in one of the Text object's TextBlocks. Matches that lie in the
overflow beyond the Text's bounds can be included however: pass the `all`
keyword as the `matches` arg.
Returns:
a list of TextFragment objects
"""
return
self
.
_seek
(
self
.
_nodes
.
get
(
tag_name
, []),
matches
)
def
_seek
(
self
,
stream
,
limit
):
found
=
[]
for
m
in
stream
:
match
=
TextFragment
(
self
,
m
)
if
not
match
.
blocks
and
limit
is
not
all
:
break
found
.
append
(
match
)
if
len
(
found
)
==
limit
:
break
return
found
@
property
def
text
(
self
):
"""Returns the unicode string being typeset"""
return
self
.
_store
.
string
()
@
property
def
words
(
self
):
"""Returns a TextFragment for each word in the text string (whitespace separated)"""
return
[
TextFragment
(
self
,
w
)
for
w
in
self
.
_store
.
words
()]
@
property
def
paragraphs
(
self
):
"""Returns a TextFragment for each `line' in the text string (newline separated)"""
return
[
TextFragment
(
self
,
w
)
for
w
in
self
.
_store
.
paragraphs
()]
@
property
def
blocks
(
self
):
"""Returns a list of one or more TextBlocks defining the bounding box for layout"""
return
list
(
self
.
_blocks
)
@
property
def
lines
(
self
):
"""Returns a list of TextFragments, one for each line across all child TextBlocks"""
return
[
TextFragment
(
self
,
slug
)
for
slug
in
foundry
.
line_slugs
(
self
)]
### Calculating dimensions & rendering ###
def
_resized
(
self
):
"""Ensure that the first TextBlock's bounds are kept in sync with the Text's.
Called by the FrameMixin when the width or size is reassigned."""
# start with the max w/h passed by the Text object
dims
=
self
.
_frame
.
size
block
=
self
.
_blocks
[
0
]
# start at the maximal size before shrinking-to-fit
block
.
offset
=
(
0
,
0
)
block
.
size
=
(
dims
.
w
,
dims
.
h
)
# if the rect isn't fully specified, size it to fit
if
not
(
dims
.
w
and
dims
.
h
):
# compute the portion that's actually filled and add 1px of extra padding to the
# calculated width (b/c believe it or not usedRectForTextContainer is buggy...)
min_w
,
min_h
=
block
.
metrics
min_w
+=
block
.
_from_px
(
1
)
# shift the offset if not left-aligned and drawing to a point
nudge
=
{
RIGHT
:
min_w
,
CENTER
:
min_w
/
2.0
}.
get
(
block
.
_alignment
)
if
nudge
and
dims
.
w
is
None
:
block
.
x
-=
nudge
# shrink-to-fit any dims that were previously undefined
if
not
dims
.
w
:
block
.
width
=
min_w
if
not
dims
.
h
:
block
.
height
=
min_h
@
property
def
_headroom
(
self
):
"""Returns the distance between the Text's origin and the top of its bounds box"""
if
not
self
.
_store
.
length
():
return
0
return
self
.
_blocks
[
0
].
_from_px
(
self
.
_engine
.
locationForGlyphAtIndex_
(
0
).
y
)
@
property
def
_flipped_transform
(
self
):
"""Returns a Transform object that positions unflipped beziers returned by trace_text"""
xf
=
Transform
()
xf
.
translate
(
self
.
x
,
self
.
y
-
self
.
_headroom
)
xf
.
scale
(
1.0
,
-
1.0
)
return
xf
@
property
def
_screen_transform
(
self
):
"""Returns the Transform object that will be used to draw the text block.
The transform incorporates the global context state but also accounts for
the column-width/height constraints set in the constructor. If the text
has been flowed to multiple TextBlocks, dimensions are calculated based on
the union of the various bounds boxes."""
# gather the relevant text metrics (and convert them from canvas- to pixel-units)
x
,
y
=
self
.
_to_px
(
Point
(
self
.
x
,
self
.
y
))
baseline
=
self
.
_to_px
(
self
.
_headroom
)
# accumulate transformations in a fresh matrix
xf
=
Transform
()
if
self
.
_transformmode
==
CENTER
:
# calculate the (reversible) translation offset for centering (in px)
bounds
=
self
.
_to_px
(
self
.
frame
)
shift
=
bounds
.
origin
+
bounds
.
size
/
2.0
-
(
x
,
y
-
baseline
)
nudge
=
Transform
().
translate
(
*
shift
)
xf
.
translate
(
x
,
y
-
baseline
)
# set the position before applying transforms
xf
.
prepend
(
nudge
)
# nudge the block to its center
xf
.
prepend
(
self
.
transform
)
# add context's CTM.
xf
.
prepend
(
nudge
.
inverse
)
# Move back to the real origin.
else
:
xf
.
prepend
(
self
.
transform
)
# in CORNER mode simply apply the CTM
xf
.
translate
(
x
,
y
-
baseline
)
# then move to the baseline origin point
return
xf
def
_draw
(
self
):
with
_ns_context
():
# save and restore the gstate
self
.
_screen_transform
.
concat
()
# transform so text can be drawn at the origin
self
.
_colorize
()
# convert from Color to NSColor using current output mode
with
self
.
effects
.
applied
():
# apply any blend/alpha/shadow effects
for
block
in
self
.
_blocks
:
px_offset
=
self
.
_to_px
(
block
.
offset
)
self
.
_engine
.
drawGlyphsForGlyphRange_atPoint_
(
block
.
_glyphs
,
px_offset
)
# debug: draw a grey background for the text's bounds
# NSColor.colorWithDeviceWhite_alpha_(0,.2).set()
# NSBezierPath.fillRect_(Region(block.offset, block.size))
@
property
def
path
(
self
):
"""Traces the laid-out glyphs and returns them as a single Bezier object"""
# generate an unflipped bezier with all the glyphs
path
=
Bezier
(
foundry
.
trace_text
(
self
))
path
.
inherit
(
self
)
# set its center-rotation fulcrum based on the blocks' bounds rect
origin
,
size
=
self
.
frame
path
.
_fulcrum
=
origin
+
size
/
2.0
# flip the assembled path and slide it into the proper x/y position
return
self
.
_flipped_transform
.
apply
(
path
)
class
TextFragment
(
object
):
"""Represents a substring region within a Text object (via its `find` or `select` method)
Properties:
`start` and `end` - the character range of the match
`text` - the matched substring
`path` - a Bezier object with the glyphs from the matched range
`lines` - a list of one or more TextFragments describing line-breaking within the Match
`blocks` - a list of one or more TextBlocks that fully contain the Match
Additional properties when .select'ing an xml element:
`tag` - a string with the matched element's name
`attrs` - a dictionary with the element's attributes (if any)
`parents` - a tuple with the parent, grandparent, etc. tag names
Additional properties when .find'ing a regular expression:
`m` - a regular expression Match object
Additional methods when .find'ing a regular expression with 'captured' sub-groups
`group(idx)` - returns a TextFragment corresponding to the numbered or named group
`groups()` - returns a list of TextFragment objects corresponding to captures in the regex
`groupdict()` - returns a dictionary mapping captured group names to TextFragment objects
"""
def
__init__
(
self
,
parent
,
match
=
None
):
self
.
_parent
=
parent
self
.
tag
,
self
.
attrs
,
self
.
parents
=
None
, {}, ()
self
.
m
=
None
if
isinstance
(
match
,
foundry
.
Slug
):
# via line_slugs()
self
.
start
,
self
.
end
=
match
.
span
self
.
_slugs
=
[
match
]
elif
hasattr
(
match
,
'range'
):
# NSSubText
self
.
start
,
n
=
match
.
range
()
self
.
end
=
self
.
start
+
n
elif
isinstance
(
match
,
Element
):
# xml Element
for
k
,
v
in
match
.
__dict__
.
items
():
setattr
(
self
,
k
,
v
)
elif
isinstance
(
match
,
TextBlock
):
# TextBlock
self
.
start
,
n
=
match
.
_chars
self
.
end
=
self
.
start
+
n
elif
hasattr
(
match
,
'span'
):
# re.Match
self
.
start
,
self
.
end
=
match
.
span
()
self
.
m
=
match
elif
isinstance
(
match
,
tuple
):
# regex sub-match
self
.
start
,
self
.
end
,
self
.
_group
=
match
def
__len__
(
self
):
return
self
.
end
-
self
.
start
def
__repr__
(
self
):
msg
=
[]
if
hasattr
(
self
,
'_group'
):
fmt
=
"r'
\\
%i'"
if
numlike
(
self
.
_group
)
else
"r'P<%s>'"
msg
.
append
(
fmt
%
self
.
_group
)
try
:
pat
=
self
.
m
.
re
.
pattern
if
len
(
pat
)
>
18
:
pat
=
"%s..."
%
(
pat
[:
15
])
msg
.
append
(
"r%s"
%
repr
(
pat
).
lstrip
(
"u"
))
except
:
if
self
.
tag
:
msg
.
append
(
"<%s>"
%
self
.
tag
)
if
self
.
attrs
:
msg
.
append
(
"attrs=%i"
%
len
(
self
.
attrs
))
msg
.
append
(
"start=%i"
%
self
.
start
)
msg
.
append
(
"len=%i"
%
(
self
.
end
-
self
.
start
))
return
'TextFragment(%s)'
%
(
", "
.
join
(
msg
))
### Contents ###
@
property
def
text
(
self
):
return
self
.
_parent
.
text
[
self
.
start
:
self
.
end
]
def
group
(
self
,
index
=
0
,
*
others
):
"""Return subgroup(s) of the match by indices or names. Index 0 returns the entire match.
Works just like https://docs.python.org/2/library/re.html#re.MatchObject.group
but returns TextFragment objects rather than character strings
"""
self
.
_is_regex
(
'group'
)
subs
=
[]
for
idx
in
(
index
,)
+
others
:
rng
=
self
.
m
.
span
(
idx
)
+
(
idx
,)
subs
.
append
(
TextFragment
(
self
.
_parent
,
rng
)
if
rng
[
0
]
!=
-
1
else
None
)
return
tuple
(
subs
)
if
len
(
subs
)
>
1
else
subs
[
0
]
def
groups
(
self
,
default
=
None
):
"""Return a tuple containing all the subgroups of the match, from 1.
The default argument is used for groups that did not participate in the match
Works just like https://docs.python.org/2/library/re.html#re.MatchObject.groups
but returns TextFragment objects rather than character strings
"""
self
.
_is_regex
(
'groups'
)
indices
=
list
(
range
(
1
,
len
(
self
.
m
.
regs
)))
if
not
indices
:
return
()
return
tuple
([
m
if
m
else
default
for
m
in
self
.
group
(
*
indices
)])
def
groupdict
(
self
,
default
=
None
):
"""Return a dictionary containing all the named subgroups of the match,
keyed by the subgroup name. The default argument is used for groups
that did not participate in the match
Works just like https://docs.python.org/2/library/re.html#re.MatchObject.groupdict
but returns TextFragment objects rather than character strings
"""
self
.
_is_regex
(
'groupdict'
)
indices
=
self
.
m
.
groupdict
().
keys
()
subs
=
[
self
.
group
(
nm
)
for
nm
in
indices
]
return
{
nm
:
sub
if
sub
else
default
for
nm
,
sub
in
zip
(
indices
,
subs
)}
def
_is_regex
(
self
,
method
):
# an assert to ensure the match supports the group* methods
if
not
self
.
m
:
badmatch
=
'%s() can only be used with regex-based TextFragment objects (see Text.find)'
raise
DeviceError
(
badmatch
%
method
)
### Geometry ###
@
property
def
slugs
(
self
):
"""A list of one or more line-fragment Slugs describing text layout within the match"""
if
not
hasattr
(
self
,
'_slugs'
):
rng
=
(
self
.
start
,
self
.
end
-
self
.
start
)
self
.
_slugs
=
foundry
.
line_slugs
(
self
.
_parent
,
rng
)
return
self
.
_slugs
@
property
def
lines
(
self
):
"""A list of one or more TextFragments splitting the current object across line-breaks"""
return
[
TextFragment
(
self
.
_parent
,
lf
)
for
lf
in
self
.
slugs
]
@
property
def
blocks
(
self
):
"""The list of TextBlock objects that the match spans"""
rng
=
(
self
.
start
,
self
.
end
-
self
.
start
)
return
foundry
.
text_blocks
(
self
.
_parent
,
rng
)
@
property
def
frame
(
self
):
"""Returns the bounding box for the lines containing the match"""
box
=
Region
()
for
slug
in
self
.
slugs
:
box
=
box
.
union
(
slug
.
frame
)
return
box
@
property
def
bounds
(
self
):
"""Returns the bounding box of the matched characters"""
box
=
Region
()
for
slug
in
self
.
slugs
:
box
=
box
.
union
(
slug
.
bounds
)
return
box
@
property
def
metrics
(
self
):
"""The size of the rendered text"""
return
self
.
bounds
.
size
@
property
def
baseline
(
self
):
"""The origin Point of the first glyph in the match (or None if empty)"""
for
slug
in
self
.
slugs
:
return
slug
.
baseline
return
None
@
property
def
path
(
self
):
"""Traces the laid-out glyphs and returns them as a single Bezier object"""
# generate an unflipped bezier with all the glyphs
path
=
Bezier
(
foundry
.
trace_text
(
self
.
_parent
, (
self
.
start
,
len
(
self
))))
path
.
inherit
(
self
.
_parent
)
# set its center-rotation fulcrum based on the blocks' bounds rect
origin
,
size
=
self
.
_parent
.
frame
path
.
_fulcrum
=
origin
+
size
/
2.0
# flip the assembled path and slide it into the proper x/y position
return
self
.
_parent
.
_flipped_transform
.
apply
(
path
)
class
TextBlock
(
FrameMixin
,
Grob
):
"""Defines a layout region for a Text object's typesetter.
Most Text objects have a single TextBlock which holds the width
and height of the layout region. You don't need to deal with it
directly since you can just set the x/y/w/h attributes on the Text
object itself.
You can create a multi-column layout by iterating over a Text
object's .flow() method and manipulating the TextBlocks it returns.
You can also inspect the existing TextBlocks without adding new ones
through the Text object's `blocks` property.
Read/Write Properties:
`offset` - a Point with the block's position relative to the parent Text's.
`size` - a Size with the maximum width & height of the layout region
`x`,`y`,`w`,`h` - shorthand accessors for offset & size components
Readable Properties:
`text` - the substring that is visible in the block
`idx` - a counter marking the block's place in the sequence
`metrics` - the size of the used portion of the block's w & h
`lines` - a list of LineFragments contained in the block
`path` - a Bezier object with all the visible glyphs in the block
"""
def
__init__
(
self
,
parent
):
# inherit the canvas-unit methods and a _frame
self
.
_frame
=
Region
((
0
,
0
), (
None
,
None
))
self
.
inherit
()
# create a new container
self
.
_block
=
NSTextContainer
.
alloc
().
init
()
self
.
_block
.
setLineFragmentPadding_
(
0
)
if
isinstance
(
parent
,
TextBlock
):
# either piggyback on an existing block...
self
.
_parent
=
parent
.
_parent
self
.
offset
,
self
.
size
=
parent
.
offset
,
parent
.
size
else
:
# ... or become the first block of a parent Text object
self
.
_parent
=
parent
# add ourselves to the layout flow
self
.
_parent
.
_engine
.
addTextContainer_
(
self
.
_block
)
@
trim_zeroes
def
__repr__
(
self
):
return
"TextBlock(%r, %r)"
%
(
tuple
(
self
.
offset
),
tuple
(
self
.
size
))
@
property
def
idx
(
self
):
"""An integer marking this block's place in the flow sequence"""
return
self
.
_parent
.
_engine
.
textContainers
().
index
(
self
.
_block
)
@
property
def
text
(
self
):
"""The portion of the parent Text object's string that is visible in this block"""
return
self
.
_parent
.
_store
.
string
().
substringWithRange_
(
self
.
_chars
)
@
property
def
frame
(
self
):
"""The position & size of the block in canvas coordinates"""
bbox
=
Region
(
self
.
offset
,
self
.
size
)
bbox
.
origin
+=
self
.
_parent
.
baseline
bbox
.
y
-=
self
.
_from_px
(
self
.
_headroom
)
return
bbox
@
property
def
bounds
(
self
):
"""The position & size of the block's text in canvas coordinates"""
self
.
_parent
.
_engine
.
glyphRangeForTextContainer_
(
self
.
_block
)
# force layout & glyph gen
origin
,
size
=
self
.
_parent
.
_engine
.
usedRectForTextContainer_
(
self
.
_block
)
origin
.
y
-=
self
.
_headroom
# adjust for the ascent above baseline
origin
+=
self
.
offset
+
self
.
_parent
.
baseline
return
self
.
_from_px
(
Region
(
origin
,
size
))
@
property
def
metrics
(
self
):
"""The size of the rendered text"""
return
self
.
bounds
.
size
@
property
def
lines
(
self
):
"""A list of TextFragments describing the line-layout within the block"""
slugs
=
foundry
.
line_slugs
(
self
.
_parent
,
self
.
_chars
)
return
[
TextFragment
(
self
.
_parent
,
slug
)
for
slug
in
slugs
]
@
property
def
path
(
self
):
"""Traces the laid-out glyphs and returns them as a single Bezier object"""
return
TextFragment
(
self
.
_parent
,
self
).
path
@
property
def
_headroom
(
self
):
if
not
self
.
_parent
.
text
:
return
self
.
_parent
.
_font
.
ascender
fnt
,
_
=
self
.
_parent
.
_store
.
attribute_atIndex_effectiveRange_
(
"NSFont"
,
self
.
_chars
.
location
,
None
);
if
not
fnt
:
return
self
.
_parent
.
_font
.
ascender
return
fnt
.
ascender
()
def
_eject
(
self
):
idx
=
self
.
_parent
.
_engine
.
textContainers
().
index
(
self
.
_block
)
self
.
_parent
.
_engine
.
removeTextContainerAtIndex_
(
idx
)
self
.
_parent
=
None
def
_resized
(
self
):
# called by the FrameMixin when the w or h changed
dims
=
[
d
or
self
.
_from_px
(
10000000
)
for
d
in
self
.
_frame
.
size
]
self
.
_block
.
setContainerSize_
(
self
.
_to_px
(
Size
(
*
dims
)))
def
_get_offset
(
self
):
return
Point
(
self
.
_frame
.
origin
)
def
_set_offset
(
self
,
dims
):
if
numlike
(
dims
):
dims
=
[
dims
]
*
2
self
.
_frame
.
origin
=
dims
offset
=
property
(
_get_offset
,
_set_offset
)
def
_get_size
(
self
):
return
self
.
_from_px
(
self
.
_block
.
containerSize
())
def
_set_size
(
self
,
dims
):
if
dims
!=
self
.
_frame
.
size
:
self
.
_frame
.
size
=
dims
self
.
_resized
()
size
=
property
(
_get_size
,
_set_size
)
@
property
def
_alignment
(
self
):
if
not
self
.
_parent
.
_store
.
string
():
return
LEFT
graf
,
_
=
self
.
_parent
.
_store
.
attribute_atIndex_effectiveRange_
(
"NSParagraphStyle"
,
0
,
None
)
return
{
_TEXT
[
a
]:
a
for
a
in
_TEXT
}.
get
(
graf
.
alignment
(),
LEFT
)
@
property
def
_glyphs
(
self
):
# NSRange of glyphs in the block
return
self
.
_parent
.
_engine
.
glyphRangeForTextContainer_
(
self
.
_block
)
@
property
def
_chars
(
self
):
# NSRange of chars in the block
rng
,
_
=
self
.
_parent
.
_engine
.
characterRangeForGlyphRange_actualGlyphRange_
(
self
.
_glyphs
,
None
)
return
rng
def
draw
(
self
):
# we inherit from Grob for the methods, not drawability
codependent
=
"TextBlocks can't be drawn directly; plot() the parent Text object instead"
raise
DeviceError
(
codependent
)
Back
|
FazBrowse Home
|
New Git URL