toaruos/util/python-demos/text_region.py
2018-10-03 10:05:22 +09:00

590 lines
25 KiB
Python

import hashlib
import subprocess
from urllib.parse import urlparse
import unicodedata
from html.parser import HTMLParser
import math
import os
import cairo
import toaru_fonts
import yutani
def create_from_bmp(path):
if os.path.exists(path) and path.endswith('.bmp'):
return yutani.Sprite.from_file(path).get_cairo_surface()
return None
_emoji_available = os.path.exists('/usr/share/emoji')
if _emoji_available:
_emoji_values = [int(x.replace('.png',''),16) for x in os.listdir('/usr/share/emoji') if x.endswith('.png') and not '-' in x]
_emoji_table = {}
def get_emoji(emoji):
if not emoji in _emoji_table:
_emoji_table[emoji] = cairo.ImageSurface.create_from_png('/usr/share/emoji/' + hex(ord(emoji)).replace('0x','')+'.png')
return _emoji_table[emoji]
class TextUnit(object):
def __init__(self, string, unit_type, font):
self.string = string
self.unit_type = unit_type
self.font = font
self.width = font.width(self.string) if font else 0
self.extra = {}
self.tag_group = None
if self.unit_type == 2 and _emoji_available:
if ord(self.string) > 0x1000 and ord(self.string) in _emoji_values:
self.extra['emoji'] = True
self.extra['img'] = get_emoji(self.string)
self.extra['offset'] = font.font_size
self.string = ""
self.width = font.font_size
def set_tag_group(self, tag_group):
self.tag_group = tag_group
self.tag_group.append(self)
def set_font(self, font):
if 'img' in self.extra: return
self.font = font
self.width = font.width(self.string) if font else 0
def set_extra(self, key, data):
self.extra[key] = data
def __repr__(self):
return "(" + self.string + "," + str(self.unit_type) + "," + str(self.width) + ")"
class TextRegion(object):
def __init__(self, x, y, width, height, font=None):
self.x = x
self.y = y
self.width = width
self.height = height
if not font:
font = toaru_fonts.Font(toaru_fonts.FONT_SANS_SERIF, 13)
self.font = font
self.text = ""
self.lines = []
self.align = 0
self.valign = 0
self.line_height = self.font.font_size
self.text_units = []
self.scroll = 0
self.ellipsis = ""
self.one_line = False
self.base_dir = ""
self.break_all = False
self.title = None
self.max_lines = None
def set_alignment(self, align):
self.align = align
def set_valignment(self, align):
self.valign = align
def set_max_lines(self, max_lines):
self.max_lines = max_lines
self.reflow()
def visible_lines(self):
return int(self.height / self.line_height)
def reflow(self):
self.lines = []
current_width = 0
current_units = []
leftover = None
i = 0
while i < len(self.text_units):
if leftover:
unit = leftover
leftover = None
else:
unit = self.text_units[i]
if unit.unit_type == 3:
self.lines.append(current_units)
current_units = []
current_width = 0
i += 1
continue
if unit.unit_type == 4:
if current_units:
self.lines.append(current_units)
i += 1
self.lines.append([unit])
current_units = []
current_width = 0
i += 1
continue
if current_width + unit.width > self.width:
if not current_units or self.one_line or (self.max_lines is not None and len(self.lines) == self.max_lines - 1):
# We need to split the current unit.
k = len(unit.string)-1
while k and current_width + unit.font.width(unit.string[:k] + self.ellipsis) > self.width:
k -= 1
ellipsis = self.ellipsis
if not k and self.ellipsis:
ellipsis = ""
if not k and (self.one_line or (self.max_lines is not None and len(self.lines) == self.max_lines - 1)):
added_ellipsis = False
while len(current_units) and sum([unit.width for unit in current_units]) + unit.font.width(self.ellipsis) > self.width:
this_unit = current_units[-1]
current_units = current_units[:-1]
current_width = sum([unit.width for unit in current_units])
k = len(this_unit.string)-1
while k and current_width + unit.font.width(this_unit.string[:k] + self.ellipsis) > self.width:
k -= 1
if k:
current_units.append(TextUnit(this_unit.string[:k] + self.ellipsis,this_unit.unit_type,this_unit.font))
added_ellipsis = True
break
if not added_ellipsis:
current_units.append(TextUnit(self.ellipsis,0,unit.font))
else:
current_units.append(TextUnit(unit.string[:k]+ellipsis,unit.unit_type,unit.font))
leftover = TextUnit(unit.string[k:],unit.unit_type,unit.font)
self.lines.append(current_units)
current_units = []
current_width = 0
if self.one_line or (self.max_lines is not None and len(self.lines) == self.max_lines):
return
else:
self.lines.append(current_units)
current_units = []
current_width = 0
if unit.unit_type == 1:
i += 1
else:
current_units.append(unit)
current_width += unit.width
i += 1
if current_units:
self.lines.append(current_units)
def units_from_text(self, text, font=None, whitespace=True):
if not font:
font = self.font
def char_width(char):
if _emoji_available and ord(char) in _emoji_values:
return 2
x = unicodedata.east_asian_width(char)
if x == 'Na': return 1 # Narrow
if x == 'N': return 1 # Narrow
if x == 'A': return 1 # Ambiguous
if x == 'W': return 2 # Wide
if x == 'F': return 1 # Fullwidth (treat as normal)
if x == 'H': return 1 # Halfwidth
print(f"Don't know how wide {x} is, assuming 1")
return 1
def classify(char):
if char == '\n': return 3 # break on line feed
if unicodedata.category(char) == 'Zs': return 1 # break on space
if char_width(char) > 1: return 2 # allow break on CJK characters (TODO: only really valid for Chinese and Japanese; Korean doesn't work this way
if self.break_all: return 2
return 0
units = []
offset = 0
current_unit = ""
while offset < len(text):
c = text[offset]
if not whitespace and c.isspace():
if current_unit:
units.append(TextUnit(current_unit,0,font))
current_unit = ""
units.append(TextUnit(' ',1,font))
offset += 1
continue
x = classify(c)
if x == 0:
current_unit += c
offset += 1
else:
if not current_unit:
units.append(TextUnit(c,x,font))
offset += 1
else:
units.append(TextUnit(current_unit,0,font))
current_unit = ""
if current_unit:
units.append(TextUnit(current_unit,0,font))
return units
def set_one_line(self, one_line=True):
self.one_line = one_line
self.reflow()
def set_ellipsis(self, ellipsis=""):
self.ellipsis = ellipsis
self.reflow()
def set_text(self, text):
self.text = text
self.text_units = self.units_from_text(text)
self.reflow()
def set_richtext(self, text, html=False):
f = self.font
self.text = text
tr = self
class RichTextParser(HTMLParser):
def __init__(self, html=False):
super(RichTextParser,self).__init__()
self.font_stack = []
self.tag_stack = []
self.current_font = f
self.units = []
self.link_stack = []
self.current_link = None
self.tag_group = None
self.is_html = html
self.whitespace_sensitive = not html
self.autoclose = ['br','meta','input']
self.title = ''
if self.is_html:
self.autoclose.extend(['img','link'])
self.surface_cache = {}
def handle_starttag(self, tag, attrs):
def make_bold(n):
if n == 0: return 1
if n == 2: return 3
if n == 4: return 5
if n == 6: return 7
return n
def make_italic(n):
if n == 0: return 2
if n == 1: return 3
if n == 4: return 6
if n == 5: return 7
return n
def make_monospace(n):
if n == 0: return 4
if n == 1: return 5
if n == 2: return 6
if n == 3: return 7
return n
if tag not in self.autoclose:
self.tag_stack.append(tag)
if tag in ['p','div','h1','h2','h3','li','tr','pre'] and not self.whitespace_sensitive: # etc?
if self.units and self.units[-1].unit_type != 3:
self.units.append(TextUnit('\n',3,self.current_font))
if tag == "b":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(make_bold(self.current_font.font_number),self.current_font.font_size,self.current_font.font_color)
elif tag == "i":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(make_italic(self.current_font.font_number),self.current_font.font_size,self.current_font.font_color)
elif tag == "color":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(self.current_font.font_number,self.current_font.font_size,int(attrs[0][0],16) | 0xFF000000)
elif tag == "mono":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(make_monospace(self.current_font.font_number),self.current_font.font_size,self.current_font.font_color)
elif tag == "pre":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(make_monospace(self.current_font.font_number),self.current_font.font_size,self.current_font.font_color)
elif tag == "link" and not self.is_html:
target = None
for attr in attrs:
if attr[0] == "target":
target = attr[1]
self.tag_group = []
self.link_stack.append(self.current_link)
self.current_link = target
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(self.current_font.font_number,self.current_font.font_size,0xFF0000FF)
elif tag == "a":
target = None
for attr in attrs:
if attr[0] == "href":
target = attr[1]
self.tag_group = []
self.link_stack.append(self.current_link)
if target and self.is_html and not target.startswith('http:') and not target.startswith('https:'):
# This is actually more complicated than this check - protocol-relative stuff can work without full URLs
if target.startswith('/'):
base = urlparse(tr.base_dir)
target = f"{base.scheme}://{base.netloc}{target}"
else:
target = tr.base_dir + target
self.current_link = target
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(self.current_font.font_number,self.current_font.font_size,0xFF0000FF)
elif tag == "h1":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(make_bold(self.current_font.font_number),20)
elif tag == "h2":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(make_bold(self.current_font.font_number),18)
elif tag == "h3":
self.font_stack.append(self.current_font)
self.current_font = toaru_fonts.Font(make_bold(self.current_font.font_number),16)
elif tag == "img":
self.handle_img(tag,attrs)
elif tag == "br":
units = tr.units_from_text('\n', self.current_font)
self.units.extend(units)
else:
pass
def handle_startendtag(self, tag, attrs):
if tag == "img":
self.handle_img(tag,attrs)
elif tag == "br":
units = tr.units_from_text('\n', self.current_font)
self.units.extend(units)
elif tag in ['p','div','h1','h2','h3','tr','pre'] and not self.whitespace_sensitive: # etc?
units = tr.units_from_text('\n', self.current_font)
self.units.extend(units)
else:
# Unknown start/end tag.
pass
def handle_endtag(self, tag):
if not self.tag_stack:
print(f"No stack when trying to close {tag}?")
if self.tag_stack[-1] != tag:
print(f"unclosed tag {self.tag_stack[-1]} when closing tag {tag}")
else:
self.tag_stack.pop()
if tag in ["b","i","color","mono","link","h1","h2","h3","a","pre"]:
self.current_font = self.font_stack.pop()
if tag in ['p','div','h1','h2','h3','li','tr','pre'] and not self.whitespace_sensitive: # etc?
units = tr.units_from_text('\n', self.current_font)
self.units.extend(units)
if tag in ["link","a"]:
self.current_link = self.link_stack.pop()
self.tag_group = None
def handle_data(self, data):
if 'title' in self.tag_stack:
self.title += data
if 'head' in self.tag_stack or 'script' in self.tag_stack: return
if 'pre' in self.tag_stack:
units = tr.units_from_text(data, self.current_font, whitespace=True)
else:
units = tr.units_from_text(data, self.current_font, whitespace=self.whitespace_sensitive)
if self.current_link:
for u in units:
u.set_extra('link',self.current_link)
if self.tag_group is not None:
for u in units:
u.set_tag_group(self.tag_group)
self.units.extend(units)
def handle_img(self, tag, attrs):
target = None
for attr in attrs:
if attr[0] == "src":
target = attr[1]
if target and self.is_html and not target.startswith('http:') and not target.startswith('https:'):
# This is actually more complicated than this check - protocol-relative stuff can work without full URLs
if target.startswith('/'):
base = urlparse(tr.base_dir)
target = f"{base.scheme}://{base.netloc}{target}"
else:
target = tr.base_dir + target
else:
if target and not self.is_html and not target.startswith('/'):
target = tr.base_dir + target
if target and self.is_html and not target.startswith('http:'):
target = tr.base_dir + target
if target and target.startswith('http:'):
x = hashlib.sha512(target.encode('utf-8')).hexdigest()
p = f'/tmp/.browser-cache.{x}'
if not os.path.exists(p):
try:
subprocess.check_output(['fetch','-o',p,target])
except:
print(f"Failed to download image: {target}")
pass
target = p
if target and os.path.exists(target):
try:
img = self.img_from_path(target)
except:
print(f"Failed to load image {target}, going to show backup image.")
img = None
if not img:
img = self.img_from_path('/usr/share/icons/16/missing.bmp')
chop = math.ceil(img.get_height() / tr.line_height)
group = []
for i in range(chop):
u = TextUnit("",4,self.current_font)
u.set_extra('img',img)
u.set_extra('offset',i * tr.line_height)
if self.current_link:
u.set_extra('link',self.current_link)
u.set_tag_group(group)
u.width = img.get_width()
self.units.append(u)
def fix_whitespace(self):
out_units = []
last_was_whitespace = False
for unit in self.units:
if unit.unit_type == 3:
last_was_whitespace = True
out_units.append(unit)
elif unit.unit_type == 1 and unit.string == ' ':
if last_was_whitespace:
continue
last_was_whitespace = True
out_units.append(unit)
else:
last_was_whitespace = False
out_units.append(unit)
self.units = out_units
def img_from_path(self, path):
if not path in self.surface_cache:
s = create_from_bmp(path)
self.surface_cache[path] = s
return s
else:
return self.surface_cache[path]
parser = RichTextParser(html=html)
parser.feed(text)
self.title = parser.title
if html:
parser.fix_whitespace()
self.text_units = parser.units
self.reflow()
def set_font(self, new_font):
self.font = new_font
self.line_height = self.font.font_size
self.reflow()
def set_line_height(self, new_line_height):
self.line_height = new_line_height
self.reflow()
def resize(self, new_width, new_height):
needs_reflow = self.width != new_width
self.width = new_width
self.height = new_height
if needs_reflow:
self.reflow()
def move(self, new_x, new_y):
self.x = new_x
self.y = new_y
def get_offset_at_index(self, index):
""" Only works for one-liners... """
if not self.lines:
return None, (0, 0, 0, 0)
left_align = 0
xline = self.lines[0]
if self.align == 1: # right align
left_align = self.width - sum([u.width for u in xline])
elif self.align == 2: # center
left_align = int((self.width - sum([u.width for u in xline])) / 2)
i = 0
for unit in xline:
if i == index:
return unit, (0, left_align, left_align, i)
left_align += unit.width
i += 1
return None, (0, left_align, left_align, i)
def pick(self, x, y):
# Determine which line this click belongs in
if x < self.x or x > self.x + self.width or y < self.y or y > self.y + self.height:
return None, None
top_align = 0
if len(self.lines) < int(self.height / self.line_height):
if self.valign == 1: # bottom
top_align = self.height - len(self.lines) * self.line_height
elif self.valign == 2: # middle
top_align = int((self.height - len(self.lines) * self.line_height) / 2)
new_y = y - top_align - self.y - 2 # fuzz factor
line = int(new_y / self.line_height)
if line < len(self.lines[self.scroll:]):
left_align = 0
xline = self.lines[self.scroll+line]
if self.align == 1: # right align
left_align = self.width - sum([u.width for u in xline])
elif self.align == 2: # center
left_align = int((self.width - sum([u.width for u in xline])) / 2)
i = 0
for unit in xline:
if x >= self.x + left_align and x < self.x + left_align + unit.width:
return unit, (line, left_align, x - self.x, i)
left_align += unit.width
i += 1
return None, (line, left_align, x - self.x, i)
return None, None
def click(self, x, y):
unit, _ = self.pick(x,y)
return unit
def draw(self, context):
current_height = self.line_height
top_align = 0
if len(self.lines) < int(self.height / self.line_height):
if self.valign == 1: # bottom
top_align = self.height - len(self.lines) * self.line_height
elif self.valign == 2: # middle
top_align = int((self.height - len(self.lines) * self.line_height) / 2)
su = context.get_cairo_surface() if 'get_cairo_surface' in dir(context) else None
cr = cairo.Context(su) if su else None
for line in self.lines[self.scroll:]:
if current_height > self.height:
break
left_align = 0
if self.align == 1: # right align
left_align = self.width - sum([u.width for u in line])
elif self.align == 2: # center
left_align = int((self.width - sum([u.width for u in line])) / 2)
for unit in line:
if unit.unit_type == 4:
cr.save()
extra = 3
cr.translate(self.x + left_align, self.y + current_height + top_align)
if 'hilight' in unit.extra and unit.extra['hilight']:
cr.rectangle(0,-self.line_height+extra,unit.extra['img'].get_width(),self.line_height)
cr.set_source_rgb(1,0,0)
cr.fill()
cr.rectangle(0,-self.line_height+extra,unit.extra['img'].get_width(),self.line_height)
cr.set_source_surface(unit.extra['img'],0,-unit.extra['offset']-self.line_height+extra)
cr.fill()
cr.restore()
elif unit.unit_type == 2 and 'emoji' in unit.extra:
cr.save()
extra = 3
cr.translate(self.x + left_align, self.y + current_height + top_align -self.line_height+extra)
if unit.extra['img'].get_height() > self.line_height - 3:
scale = (self.line_height - 3) / unit.extra['img'].get_height()
cr.scale(scale,scale)
cr.rectangle(0,0,unit.extra['img'].get_width(),unit.extra['img'].get_height())
cr.set_source_surface(unit.extra['img'],0,0)
cr.fill()
cr.restore()
elif unit.font:
unit.font.write(context, self.x + left_align, self.y + current_height + top_align, unit.string)
left_align += unit.width
current_height += self.line_height