From 039ea9adda1ab9338165469982ae1be6dcce3ae7 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 4 Mar 2023 01:41:34 +0200 Subject: [PATCH] [justify] Add demo GTK app --- src/justify.py | 364 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 src/justify.py diff --git a/src/justify.py b/src/justify.py new file mode 100644 index 000000000..0677bd7e0 --- /dev/null +++ b/src/justify.py @@ -0,0 +1,364 @@ +import gi + +from collections import namedtuple + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, HarfBuzz as hb + + +POOL = {} + + +def move_to_f(funcs, draw_data, st, to_x, to_y, user_data): + context = POOL[draw_data] + context.move_to(to_x, to_y) + + +def line_to_f(funcs, draw_data, st, to_x, to_y, user_data): + context = POOL[draw_data] + context.line_to(to_x, to_y) + + +def cubic_to_f( + funcs, + draw_data, + st, + control1_x, + control1_y, + control2_x, + control2_y, + to_x, + to_y, + user_data, +): + context = POOL[draw_data] + context.curve_to(control1_x, control1_y, control2_x, control2_y, to_x, to_y) + + +def close_path_f(funcs, draw_data, st, user_data): + context = POOL[draw_data] + context.close_path() + + +DFUNCS = hb.draw_funcs_create() +hb.draw_funcs_set_move_to_func(DFUNCS, move_to_f, None) +hb.draw_funcs_set_line_to_func(DFUNCS, line_to_f, None) +hb.draw_funcs_set_cubic_to_func(DFUNCS, cubic_to_f, None) +hb.draw_funcs_set_close_path_func(DFUNCS, close_path_f, None) + + +def push_transform_f(funcs, paint_data, xx, yx, xy, yy, dx, dy, user_data): + raise NotImplementedError + + +def pop_transform_f(funcs, paint_data, user_data): + raise NotImplementedError + + +def color_f(funcs, paint_data, is_foreground, color, user_data): + context = POOL[paint_data] + r = hb.color_get_red(color) / 255 + g = hb.color_get_green(color) / 255 + b = hb.color_get_blue(color) / 255 + a = hb.color_get_alpha(color) / 255 + context.set_source_rgba(r, g, b, a) + context.paint() + + +def push_clip_rectangle_f(funcs, paint_data, xmin, ymin, xmax, ymax, user_data): + context = POOL[paint_data] + context.save() + context.rectangle(xmin, ymin, xmax, ymax) + context.clip() + + +def push_clip_glyph_f(funcs, paint_data, glyph, font, user_data): + context = POOL[paint_data] + context.save() + context.new_path() + hb.font_draw_glyph(font, glyph, DFUNCS, paint_data) + context.close_path() + context.clip() + + +def pop_clip_f(funcs, paint_data, user_data): + context = POOL[paint_data] + context.restore() + + +def push_group_f(funcs, paint_data, user_data): + raise NotImplementedError + + +def pop_group_f(funcs, paint_data, mode, user_data): + raise NotImplementedError + + +PFUNCS = hb.paint_funcs_create() +hb.paint_funcs_set_push_transform_func(PFUNCS, push_transform_f, None) +hb.paint_funcs_set_pop_transform_func(PFUNCS, pop_transform_f, None) +hb.paint_funcs_set_color_func(PFUNCS, color_f, None) +hb.paint_funcs_set_push_clip_glyph_func(PFUNCS, push_clip_glyph_f, None) +hb.paint_funcs_set_push_clip_rectangle_func(PFUNCS, push_clip_rectangle_f, None) +hb.paint_funcs_set_pop_clip_func(PFUNCS, pop_clip_f, None) +hb.paint_funcs_set_push_group_func(PFUNCS, push_group_f, None) +hb.paint_funcs_set_pop_group_func(PFUNCS, pop_group_f, None) + + +class Word: + def __init__(self, font, text): + self._text = text + self._font = font + self._glyphs = [] + self._positions = [] + + def append(self, info, pos): + self._glyphs.append(info) + self._positions.append(pos) + + def draw(self, context, font): + for info, pos in zip(self._glyphs, self._positions): + context.translate(-pos.x_advance, pos.y_advance) + context.save() + context.translate(pos.x_offset, pos.y_offset) + hb.font_paint_glyph(font, info.codepoint, PFUNCS, id(context), 0, 0x0000FF) + context.restore() + + @property + def advance(self): + return sum(pos.x_advance for pos in self._positions) + + @property + def strippedadvance(self): + w = self.advance + if len(self) and self._text[self._glyphs[-1].cluster] == " ": + w -= self._positions[-1].x_advance + return w + + def __str__(self): + if not self._glyphs: + return "" + first = min(g.cluster for g in self._glyphs) + last = max(g.cluster for g in self._glyphs) + return self._text[first : last + 1] + + def __len__(self): + return len(self._glyphs) + + def __bool__(self): + return len(self) != 0 + + def __repr__(self): + return f"" + + +class Line: + def __init__(self, font, target_advance): + self._font = font + self._target_advance = target_advance + self._words = [] + self._variation = None + + def append(self, word): + self._words.append(word) + + def pop(self): + return self._words.pop() + + def justify(self): + buf, text = makebuffer(str(self)) + + advance = self.advance + shrink = self._target_advance > advance + expand = not shrink + + ret, advance, tag, value = hb.shape_justify( + self._font, + buf, + None, + None, + self._target_advance, + self._target_advance, + advance, + ) + + if not ret: + return False + if shrink and advance > self._target_advance: + return False + if expand and advance < self._target_advance: + return False + + self._variation = hb.variation_t() + self._variation.tag = tag + self._variation.value = value + self._words = makewords(buf, self._font, text) + return True + + def draw(self, context): + context.save() + context.move_to(-1600, -200) + context.set_font_size(130) + context.set_source_rgb(1, 0, 0) + if self._variation: + tag = hb.tag_to_string(self._variation.tag).decode("ascii") + context.show_text(f" {tag}={self._variation.value:g}") + context.move_to(-1600, 0) + context.show_text(f" {self.advance:g}/{self._target_advance:g}") + context.restore() + + if self._variation: + hb.font_set_variations(self._font, [self._variation]) + + context.scale(1, -1) + context.translate(self._target_advance, 0) + for word in self._words: + word.draw(context, self._font) + + @property + def advance(self): + w = sum(word.advance for word in self._words[:-1]) + if len(self): + w += self._words[-1].strippedadvance + return w + + def __str__(self): + return "".join(str(w) for w in self._words) + + def __len__(self): + return len(self._words) + + def __bool__(self): + return len(self) != 0 + + def __repr__(self): + return f"" + + +Configuration = namedtuple( + "Configuration", ["width", "height", "fontsize", "fontpath", "textpath"] +) + + +def makebuffer(text): + buf = hb.buffer_create() + hb.buffer_set_direction(buf, hb.direction_t.RTL) + hb.buffer_set_script(buf, hb.script_t.ARABIC) + hb.buffer_set_language(buf, hb.language_from_string(b"ar")) + + # Strip and remove double spaces. + text = " ".join(text.split()) + + hb.buffer_add_codepoints(buf, [ord(c) for c in text], 0, len(text)) + + return buf, text + + +def makewords(buf, font, text): + hb.buffer_reverse(buf) + words = [Word(font, text)] + infos = hb.buffer_get_glyph_infos(buf) + positions = hb.buffer_get_glyph_positions(buf) + for info, pos in zip(infos, positions): + words[-1].append(info, pos) + if text[info.cluster] == " ": + words.append(Word(font, text)) + return words + + +def typeset(conf): + blob = hb.blob_create_from_file(conf.fontpath) + face = hb.face_create(blob, 0) + font = hb.font_create(face) + + with open(conf.textpath) as f: + text = f.read() + + margin = conf.fontsize * 2 + scale = conf.fontsize / hb.face_get_upem(face) + target_advance = (conf.width - (margin * 2)) / scale + + buf, text = makebuffer(text) + + hb.shape(font, buf) + + words = makewords(buf, font, text) + + lines = [Line(font, target_advance)] + + for word in words: + lines[-1].append(word) + if lines[-1].advance > target_advance: + if lines[-1].justify(): + # Shrink + lines.append(Line(font, target_advance)) + else: + # Remove last word and expand + lines[-1].pop() + lines[-1].justify() + lines.append(Line(font, target_advance)) + lines[-1].append(word) + + if lines[-1].advance != target_advance: + lines[-1].justify() + + return lines, font + + +def render(context, conf): + lines, font = typeset(conf) + + margin = conf.fontsize * 2 + scale = conf.fontsize / hb.face_get_upem(hb.font_get_face(font)) + + _, extents = hb.font_get_h_extents(font) + lineheight = extents.ascender - extents.descender + extents.line_gap + lineheight *= scale + + context.save() + context.translate(0, margin) + for line in lines: + context.translate(0, lineheight) + + context.save() + context.translate(margin, 0) + context.scale(scale, scale) + line.draw(context) + context.restore() + context.restore() + + +def main(fontpath, textpath): + def on_draw(da, context): + alloc = da.get_allocation() + conf = Configuration( + width=alloc.width, + height=alloc.height, + fontsize=70, + fontpath=fontpath, + textpath=textpath, + ) + POOL[id(context)] = context + render(context, conf) + del POOL[id(context)] + + drawingarea = Gtk.DrawingArea() + drawingarea.connect("draw", on_draw) + + win = Gtk.Window() + win.connect("destroy", Gtk.main_quit) + win.set_default_size(1000, 700) + win.add(drawingarea) + + win.show_all() + Gtk.main() + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="HarfBuzz justification demo.") + parser.add_argument("fontfile", help="input Glyphs source file") + parser.add_argument("textfile", help="font version") + args = parser.parse_args() + main(args.fontfile, args.textfile)