Skip to content

Commit c5c1e44

Browse files
authored
make FreeTypeExt thread-safe (#394)
1 parent d279973 commit c5c1e44

File tree

4 files changed

+93
-63
lines changed

4 files changed

+93
-63
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,9 @@ jobs:
2222
- '1'
2323
experimental:
2424
- false
25-
os: [ubuntu-latest]
25+
os: [ubuntu-latest, windows-latest, macos-latest]
2626
arch: [x64]
27-
include: # spare windows/macos CI credits
28-
- os: windows-latest
29-
experimental: false
30-
version: '1'
31-
arch: x64
32-
- os: macOS-latest
33-
experimental: false
34-
version: '1'
35-
arch: x64
27+
include:
3628
- os: ubuntu-latest
3729
experimental: true
3830
version: 'nightly'
@@ -49,12 +41,12 @@ jobs:
4941
- uses: julia-actions/julia-runtest@latest
5042
env:
5143
JULIA_DEBUG: 'Main,UnicodePlots'
52-
COLORTERM: 'truecolor' # 24bit
44+
COLORTERM: 'truecolor' # 24bit
5345
- uses: julia-actions/julia-runtest@latest
5446
if: startsWith(matrix.os, 'ubuntu')
5547
env:
5648
JULIA_DEBUG: 'Main,UnicodePlots'
57-
COLORTERM: 'yes' # 8bit - 256 colors
49+
COLORTERM: 'invalid' # 8bit - 256 colors
5850
- uses: julia-actions/julia-processcoverage@latest
5951
- uses: codecov/codecov-action@v4
6052
with:

ext/FreeTypeExt.jl

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ using FreeType
1212

1313
const REGULAR_STYLES = "regular", "normal", "medium", "standard", "roman", "book"
1414
const FT_LIB = FT_Library[C_NULL]
15+
const LIB_LOCK = ReentrantLock()
1516
const VALID_FONTPATHS = String[]
1617

1718
struct FontExtent{T}
@@ -23,16 +24,21 @@ end
2324

2425
mutable struct FTFont
2526
ft_ptr::FT_Face
27+
lock::ReentrantLock # lock this for the duration of any FT operation on ft_ptr
2628
function FTFont(ft_ptr::FT_Face)
27-
face = new(ft_ptr)
28-
finalizer(
29-
face -> (face.ft_ptr != C_NULL && FT_LIB[1] != C_NULL) && FT_Done_Face(face),
30-
face,
31-
)
29+
face = new(ft_ptr, ReentrantLock())
30+
finalizer(safe_free, face)
3231
face
3332
end
3433
end
35-
FTFont(path::String) = FTFont(newface(path))
34+
35+
function safe_free(face::FTFont)
36+
@lock face.lock begin
37+
(face.ft_ptr != C_NULL && FT_LIB[1] != C_NULL) && FT_Done_Face(face)
38+
end
39+
end
40+
41+
FTFont(path::String) = FTFont(new_face(path))
3642
FTFont(::Nothing) = nothing
3743

3844
family_name(font::FTFont) = lowercase(ft_property(font, :family_name))
@@ -43,9 +49,9 @@ Base.propertynames(font::FTFont) = fieldnames(FT_FaceRec)
4349
Base.cconvert(::Type{FT_Face}, font::FTFont) = font
4450
Base.unsafe_convert(::Type{FT_Face}, font::FTFont) = font.ft_ptr
4551

46-
function ft_property(font::FTFont, fieldname::Symbol)
47-
fontrect = unsafe_load(font.ft_ptr)
48-
if (field = getfield(fontrect, fieldname)) isa Ptr{FT_String}
52+
function ft_property(face::FTFont, fieldname::Symbol)
53+
font_rect = @lock face.lock unsafe_load(face.ft_ptr)
54+
if (field = getfield(font_rect, fieldname)) isa Ptr{FT_String}
4955
field == C_NULL && return ""
5056
unsafe_string(field)
5157
else
@@ -60,10 +66,10 @@ Base.show(io::IO, font::FTFont) = print(
6066

6167
check_error(err, error_msg) = err == 0 || error("$error_msg with error: $err")
6268

63-
function newface(facename, faceindex::Real = 0, ftlib = FT_LIB)
69+
function new_face(name, index::Real = 0, ftlib = FT_LIB)
6470
face = Ref{FT_Face}()
65-
err = FT_New_Face(ftlib[1], facename, Int32(faceindex), face)
66-
check_error(err, "Couldn't load font $facename")
71+
err = @lock LIB_LOCK FT_New_Face(ftlib[1], name, Int32(index), face)
72+
check_error(err, "Couldn't load font $name")
6773
face[]
6874
end
6975

@@ -87,11 +93,12 @@ fallback_fonts() =
8793
const FT_FONTS = Dict{String,FTFont}()
8894

8995
"""
90-
Match a font using the user-specified search string. Each part of the search string
91-
is searched in the family name first which has to match once to include the font
92-
in the candidate list. For fonts with a family match the style
93-
name is matched next. For fonts with the same family and style name scores, regular
94-
fonts are preferred (any font that is "regular", "normal", "medium", "standard" or "roman")
96+
Match a font using the user-specified search string.
97+
Each part of the search string is searched in the family name first,
98+
which has to match once to include the font in the candidate list.
99+
For fonts with a family match the style name is matched next.
100+
For fonts with the same family and style name scores, regular fonts are preferred
101+
(any font that is "regular", "normal", "medium", "standard" or "roman"),
95102
and as a last tie-breaker, shorter overall font names are preferred.
96103
97104
Example:
@@ -163,7 +170,7 @@ function find_font(searchstring::String; additional_fonts::String = "")
163170
# we can compare all four tuple elements of the score at once in order of importance:
164171
# 1. number of family match characters
165172
# 2. number of style match characters
166-
# 3. is font a "regular" style variant?
173+
# 3. is font a "regular" style variant ?
167174
# 4. the negative length of the font name, the shorter the better
168175
if (family_match_score = first(score)) > 0 && score > best_score
169176
best_fpath = fpath
@@ -201,34 +208,39 @@ FontExtent(func::Function, ext::FontExtent) = FontExtent(
201208
)
202209

203210
function set_pixelsize(face::FTFont, size::Integer)
204-
check_error(FT_Set_Pixel_Sizes(face, size, size), "Couldn't set pixelsize")
211+
@lock face.lock check_error(
212+
FT_Set_Pixel_Sizes(face, size, size),
213+
"Couldn't set pixelsize",
214+
)
205215
size
206216
end
207217

208-
glyph_index(face::FTFont, glyphname::String)::UInt64 = FT_Get_Name_Index(face, glyphname)
209-
glyph_index(face::FTFont, char::Char)::UInt64 = FT_Get_Char_Index(face, char)
218+
glyph_index(face::FTFont, glyphname::String)::UInt64 =
219+
@lock face.lock FT_Get_Name_Index(face, glyphname)
220+
glyph_index(face::FTFont, char::Char)::UInt64 =
221+
@lock face.lock FT_Get_Char_Index(face, char)
210222
glyph_index(face::FTFont, idx::Integer) = UInt64(idx)
211223

212224
function kerning(face::FTFont, glyphspecs...)
213225
i1, i2 = glyph_index.(Ref(face), glyphspecs)
214226
kerning2d = Ref{FT_Vector}()
215-
err = FT_Get_Kerning(face, i1, i2, FT_KERNING_DEFAULT, kerning2d)
216-
# can error if font has no kerning! Since that's somewhat expected, we just return 0
227+
err = @lock face.lock FT_Get_Kerning(face, i1, i2, FT_KERNING_DEFAULT, kerning2d)
228+
# can error if font has no kerning ! Since that's somewhat expected, we just return 0
217229
err == 0 || return SVector(0.0, 0.0)
218230
divisor = 64 # 64 since metrics are in 1/64 units (units to 26.6 fractional pixels)
219231
SVector(kerning2d[].x / divisor, kerning2d[].y / divisor)
220232
end
221233

222234
function load_glyph(face::FTFont, glyph)
223235
gi = glyph_index(face, glyph)
224-
err = FT_Load_Glyph(face, gi, FT_LOAD_RENDER)
236+
err = @lock face.lock FT_Load_Glyph(face, gi, FT_LOAD_RENDER)
225237
check_error(err, "Could not load glyph $(repr(glyph)) from $face to render.")
226238
end
227239

228240
function load_glyph(face::FTFont, glyph, pixelsize::Integer; set_pix = true)
229241
set_pix && set_pixelsize(face, pixelsize)
230242
load_glyph(face, glyph)
231-
gl = unsafe_load(ft_property(face, :glyph))
243+
gl = @lock face.lock unsafe_load(ft_property(face, :glyph))
232244
@assert gl.format == FT_GLYPH_FORMAT_BITMAP
233245
gl
234246
end
@@ -258,7 +270,7 @@ one_or_typemax(::Type{T}) where {T<:Union{Real,Colorant}} =
258270

259271
"""
260272
render_string!(img::AbstractMatrix, str::String, face, pixelsize, y0, x0;
261-
fcolor=one_or_typemax(T), bcolor=zero(T), halign=:hleft, valign=:vbaseline) -> Matrix
273+
fcolor=one_or_typemax(T), bcolor=zero(T), halign=:hleft, valign=:vbaseline) -> Matrix
262274
Render `str` into `img` using the font `face` of size `pixelsize` at coordinates `y0,x0`.
263275
Uses the conventions of freetype.org/freetype2/docs/glyphs/glyphs-3.html
264276
# Arguments
@@ -414,16 +426,22 @@ function UnicodePlots.render_string!(
414426
end
415427

416428
function ft_init()
417-
FT_LIB[1] != C_NULL && error("Freetype already initialized. init() called two times ?")
418-
FT_Init_FreeType(FT_LIB) == 0
429+
@lock LIB_LOCK begin
430+
FT_LIB[1] != C_NULL &&
431+
error("Freetype already initialized. init() called two times ?")
432+
FT_Init_FreeType(FT_LIB) == 0
433+
end
419434
end
420435

421436
function ft_done()
422-
FT_LIB[1] == C_NULL &&
423-
error("Library == CNULL. done() called before init(), or done called two times ?")
424-
err = FT_Done_FreeType(FT_LIB[1])
425-
FT_LIB[1] = C_NULL
426-
err == 0
437+
@lock LIB_LOCK begin
438+
FT_LIB[1] == C_NULL && error(
439+
"Library == CNULL. done() called before init(), or done called two times ?",
440+
)
441+
err = FT_Done_FreeType(FT_LIB[1])
442+
FT_LIB[1] = C_NULL
443+
err == 0
444+
end
427445
end
428446

429447
add_recursive(result, path) =
@@ -441,11 +459,11 @@ function __init__()
441459
# so we supply a way to help it with an environment variable.
442460
font_paths = if Sys.isapple() # COV_EXCL_LINE
443461
[
444-
"/Library/Fonts", # Additional fonts that can be used by all users. This is generally where fonts go if they are to be used by other applications.
445-
joinpath(homedir(), "Library/Fonts"), # Fonts specific to each user.
446-
"/Network/Library/Fonts", # Fonts shared for users on a network
447-
"/System/Library/Fonts", # System specific fonts
448-
"/System/Library/Fonts/Supplemental", # new location since Catalina
462+
"/Library/Fonts", # additional fonts that can be used by all users: this is generally where fonts go if they are to be used by other applications
463+
joinpath(homedir(), "Library/Fonts"), # fonts specific to each user
464+
"/Network/Library/Fonts", # fonts shared for users on a network
465+
"/System/Library/Fonts", # system specific fonts
466+
"/System/Library/Fonts/Supplemental", # new location since Catalina
449467
]
450468
elseif Sys.iswindows() # COV_EXCL_LINE
451469
[

test/tst_freetype.jl

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ const FTE = if isdefined(Base, :get_extension)
33
else
44
UnicodePlots.FreeTypeExt
55
end
6-
push!(FTE.VALID_FONTPATHS, joinpath(@__DIR__, "fonts"))
6+
const FT_DIR = joinpath(@__DIR__, "fonts")
7+
push!(FTE.VALID_FONTPATHS, FT_DIR)
78

89
@testset "init and done" begin
910
@test_throws ErrorException FTE.ft_init()
@@ -247,4 +248,21 @@ end
247248
@test FTE.fallback_fonts() isa Tuple
248249
end
249250

251+
@testset "thread safety" begin
252+
mktempdir() do dir
253+
n = 100
254+
fontfiles = map(1:n) do i
255+
cp(joinpath(FT_DIR, "hack_regular.ttf"), joinpath(dir, "hack_regular_$i.ttf"))
256+
end
257+
Threads.@threads for f in fontfiles
258+
fo = FTE.FTFont(f)
259+
Threads.@threads for i = 1:n
260+
FTE.load_glyph(fo, i)
261+
FTE.load_glyph(fo, i, 64)
262+
FTE.render_face(fo, i, 16)
263+
end
264+
end
265+
end
266+
end
267+
250268
pop!(FTE.VALID_FONTPATHS)

test/tst_imageplot.jl

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,26 @@ img = testimage("monarch_color_256")
22

33
if !is_ci() || UnicodePlots.colormode() == 24 # FIXME: failure on ci with 8bit mode
44
@testset "blocks" begin
5-
_old_enc = ImageInTerminal.ENCODER_BACKEND[]
6-
ImageInTerminal.ENCODER_BACKEND[] = :XTermColors
7-
p = imageplot(img, title = "blocks")
8-
test_ref("imageplot/img_blocks.txt", @show_col(p, :displaysize => T_SZ))
9-
@test !p.graphics.sixel[]
10-
ImageInTerminal.ENCODER_BACKEND[] = _old_enc
5+
let _old_enc = ImageInTerminal.ENCODER_BACKEND[]
6+
ImageInTerminal.ENCODER_BACKEND[] = :XTermColors
7+
p = imageplot(img, title = "blocks")
8+
test_ref("imageplot/img_blocks.txt", @show_col(p, :displaysize => T_SZ))
9+
@test !p.graphics.sixel[]
10+
ImageInTerminal.ENCODER_BACKEND[] = _old_enc
11+
end
1112
end
1213
end
1314

1415
# experimental testing: see github.com/JuliaLang/Pkg.jl/pull/3186
1516
# must be launched with `Pkg.test("UnicodePlots"; forward_stdin=true)` on `1.8`+
1617
if !is_ci() && ImageInTerminal.Sixel.is_sixel_supported()
1718
@testset "sixel" begin
18-
_old_enc = ImageInTerminal.ENCODER_BACKEND[]
19-
ImageInTerminal.ENCODER_BACKEND[] = :Sixel
20-
p = imageplot(img, title = "sixel")
21-
test_ref("imageplot/img_sixel.txt", @show_col(p, :displaysize => T_SZ))
22-
@test p.graphics.sixel[]
23-
ImageInTerminal.ENCODER_BACKEND[] = _old_enc
19+
let _old_enc = ImageInTerminal.ENCODER_BACKEND[]
20+
ImageInTerminal.ENCODER_BACKEND[] = :Sixel
21+
p = imageplot(img, title = "sixel")
22+
test_ref("imageplot/img_sixel.txt", @show_col(p, :displaysize => T_SZ))
23+
@test p.graphics.sixel[]
24+
ImageInTerminal.ENCODER_BACKEND[] = _old_enc
25+
end
2426
end
2527
end

0 commit comments

Comments
 (0)