forked from balisujohn/localwriter
-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathrich_text.py
More file actions
317 lines (261 loc) · 11.8 KB
/
Copy pathrich_text.py
File metadata and controls
317 lines (261 loc) · 11.8 KB
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
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2026 KeithCu
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Shared rich-text formatting for the RichTextControl sidebar (hidden Writer HTML import)."""
import logging
import re
from typing import Any, cast
log = logging.getLogger(__name__)
_HTML_TAG_RE = re.compile(
r"<(?:"
r"p[>\s/]"
r"|br[\s/>]"
r"|/h[1-6]"
r"|ul[\s/>]"
r"|ol[\s/>]"
r"|li[\s/>]"
r"|strong[\s/>]"
r"|em[\s/>]"
r"|code[\s/>]"
r"|pre[\s/>]"
r"|div[\s/>]"
r"|table[\s/>]"
r")",
re.IGNORECASE,
)
# Legacy plain-sidebar prefix; append_rich_text adds "Assistant:" instead.
_LEGACY_AI_LABEL_RE = re.compile(r"^\s*AI:\s*", re.IGNORECASE)
# Tight list margins for the narrow sidebar transcript (injected via shared HTML import).
_SIDEBAR_LIST_CSS = "ul, ol { margin-left: 0.2cm; padding-left: 0.3cm; }"
CHAT_FONT_NAME = "Liberation Sans"
CHAT_FONT_HEIGHT = 10.0
CHAT_FONT_WEIGHT = 100.0
# Writer paragraph margins (1/100 mm) — horizontal padding inside RichTextControl EditEngine.
CHAT_PARA_SIDE_MARGIN = 250
def apply_chat_char_props(target, *, bg_color=None) -> None:
"""Apply sidebar chat Liberation Sans 10pt Char* props to a cursor, portion, or style object."""
for name, val in (
("CharFontName", CHAT_FONT_NAME),
("CharFontNameAsian", CHAT_FONT_NAME),
("CharFontNameComplex", CHAT_FONT_NAME),
("CharHeight", CHAT_FONT_HEIGHT),
("CharWeight", CHAT_FONT_WEIGHT),
("CharPosture", 0),
):
try:
setattr(target, name, val)
except Exception:
pass
if bg_color is not None:
try:
target.CharBackColor = bg_color
except Exception:
pass
def apply_rich_control_para_margins(cursor) -> None:
"""Keep chat text off the RichTextControl edges (EditEngine has no CSS padding)."""
for name, val in (
("ParaLeftMargin", CHAT_PARA_SIDE_MARGIN),
("ParaRightMargin", CHAT_PARA_SIDE_MARGIN),
("ParaFirstLineIndent", 0),
):
try:
setattr(cursor, name, val)
except Exception:
pass
def configure_hidden_writer_for_chat(doc) -> None:
"""Apply sidebar chat defaults on a hidden Writer doc (font, zero margins, no spellcheck)."""
try:
import uno
style_families = doc.getStyleFamilies()
if style_families.hasByName("ParagraphStyles"):
para_styles = style_families.getByName("ParagraphStyles")
if para_styles.hasByName("Standard"):
std_para = para_styles.getByName("Standard")
std_para.ParaLeftMargin = 0
std_para.ParaRightMargin = 0
std_para.ParaFirstLineIndent = 0
std_para.ParaTopMargin = 0
std_para.ParaBottomMargin = 200
apply_chat_char_props(std_para)
no_lang = cast("Any", uno.createUnoStruct("com.sun.star.lang.Locale"))
no_lang.Language = "zxx"
no_lang.Country = ""
std_para.CharLocale = no_lang
std_para.CharLocaleAsian = no_lang
std_para.CharLocaleComplex = no_lang
text = doc.getText()
cursor = text.createTextCursor()
cursor.gotoStart(False)
cursor.gotoEnd(True)
cursor.CharHeight = CHAT_FONT_HEIGHT
except Exception as e:
log.debug("configure_hidden_writer_for_chat failed: %s", e)
def strip_legacy_ai_label(text: str) -> str:
"""Remove leading ``AI:`` from greeting/assistant text (avoid ``Assistant: AI:``)."""
if not text:
return text
return _LEGACY_AI_LABEL_RE.sub("", text, count=1)
USER_COLOR = 0x2A6099
ASSISTANT_COLOR = 0x1E293B
class ChatTheme:
"""Encapsulates theme-aware colors derived from StyleSettings."""
def __init__(self, bg_color: int, user_color: int, assistant_color: int):
self.bg_color = bg_color
self.user_color = user_color
self.assistant_color = assistant_color
@classmethod
def resolve(cls, doc=None, style_window=None) -> "ChatTheme":
"""Factory method to resolve colors from style_window or document frame."""
bg_color, user_color, assistant_color = get_theme_colors(doc, style_window=style_window)
return cls(bg_color, user_color, assistant_color)
class HiddenDocHTMLImporter:
"""Encapsulates importing HTML into a document and tightening indents on lists."""
def __init__(self, doc):
self.doc = doc
def insert_html_at_cursor(self, cursor, html_fragment: str) -> None:
"""Import an HTML fragment into self.doc at *cursor* using Writer's HTML filter."""
_insert_html_at_cursor(self.doc, cursor, html_fragment)
def tighten_list_indent(self, body_range) -> None:
"""Tighten indentation on list paragraphs within *body_range*."""
_tighten_list_indent(body_range)
# Re-export / delegate to shared implementation (single source of truth for
# StyleSettings luminance + dark/light decision used by chat + Monaco editor).
from plugin.framework.appearance import get_theme_colors # noqa: F401
# The old implementation body has been extracted to appearance.py to avoid
# duplication and make the Monaco editor theme follow LO automatically.
def _tighten_list_indent(body_range):
"""Tighten indentation on list paragraphs within *body_range*.
The HTML filter imports <ul>/<ol> as indented paragraphs using ParaLeftMargin
(not Writer's NumberingRules mechanism). This function detects paragraphs with
non-zero ParaLeftMargin and reduces them to tight values suitable for the
narrow sidebar.
"""
import uno
try:
enum = body_range.createEnumeration()
except Exception as e:
log.debug("_tighten_list_indent: createEnumeration failed: %s", e)
return
para_count = 0
tightened = 0
processed_levels = set()
while enum.hasMoreElements():
para = enum.nextElement()
para_count += 1
try:
if not para.getPropertyValue("NumberingIsNumber"):
continue
except Exception:
continue
try:
level = para.getPropertyValue("NumberingLevel")
list_id = para.getPropertyValue("ListId")
except Exception:
continue
key = (list_id, level)
if key in processed_levels:
continue
processed_levels.add(key)
try:
rules = para.getPropertyValue("NumberingRules")
props = list(rules.getByIndex(level))
# Read the existing FirstLineOffset so we can position the bullet
# with a small left gap while preserving the original bullet-to-text spacing
flo = 0
for p in props:
if p.Name == "FirstLineOffset":
flo = p.Value
break
for p in props:
if p.Name == "LeftMargin":
log.debug("_tighten_list_indent: level=%d orig LeftMargin=%s text=%r", level, p.Value, para.getString()[:40])
p.Value = abs(flo) + 115 + level * 225
any_props = uno.Any("[]com.sun.star.beans.PropertyValue", cast("Any", tuple(props))) # type: ignore[attr-defined]
uno.invoke(rules, "replaceByIndex", (level, any_props))
para.NumberingRules = rules
tightened += 1
except Exception as e:
log.debug("_tighten_list_indent: failed for level %d: %s", level, e)
log.debug("_tighten_list_indent: scanned %d paragraphs, tightened %d", para_count, tightened)
def _insert_html_at_cursor(doc, cursor, html_fragment):
"""Import an HTML fragment into *doc* at *cursor* using Writer's HTML filter."""
from plugin.writer.format import insert_html_fragment_at_cursor
insert_html_fragment_at_cursor(cursor, html_fragment, extra_css=_SIDEBAR_LIST_CSS)
def append_rich_text(doc, text, role="assistant", style_window=None):
"""Append a complete message to a Writer document (hidden doc for RichTextControl copy).
Inserts a bold, colored role prefix (``You:`` / ``Assistant:``) then
imports *text* as HTML via Writer's StarWriter HTML filter so that
``<strong>``, ``<em>``, ``<code>``, ``<ul>`` etc. render natively.
"""
try:
text_obj = doc.getText()
cursor = text_obj.createTextCursor()
cursor.gotoEnd(False)
theme = ChatTheme.resolve(doc, style_window=style_window)
importer = HiddenDocHTMLImporter(doc)
if text and text.strip():
text = strip_legacy_ai_label(text) if role == "assistant" else text
from plugin.calc.navigation import render_calc_cell_refs
text = render_calc_cell_refs(text)
if text_obj.getString():
text_obj.insertString(cursor, "\n\n", False)
# Bold colored role prefix
start_pos = cursor.getStart()
prefix = "You: " if role == "user" else "Assistant: "
text_obj.insertString(cursor, prefix, False)
prefix_range = text_obj.createTextCursorByRange(start_pos)
prefix_range.gotoRange(cursor.getStart(), True)
prefix_range.CharHeight = CHAT_FONT_HEIGHT
prefix_range.CharWeight = 150.0 # BOLD
prefix_range.CharColor = theme.user_color if role == "user" else theme.assistant_color
# Body content via HTML import
cursor.gotoEnd(False)
cursor.CharWeight = CHAT_FONT_WEIGHT # Reset to normal after bold prefix
pre_len = doc.CharacterCount
if text and text.strip():
looks_html = bool(_HTML_TAG_RE.search(text))
log.debug("append_rich_text: looks_html=%s len=%d snippet=%r", looks_html, len(text), text[:120])
used_html_import = False
if looks_html:
try:
importer.insert_html_at_cursor(cursor, text)
used_html_import = True
except Exception:
log.debug("HTML import failed, falling back to plain text insert")
cursor.gotoEnd(False)
text_obj.insertString(cursor, text, False)
else:
text_obj.insertString(cursor, text, False)
# Build a range covering only the newly inserted content
body_range = text_obj.createTextCursor()
body_range.gotoStart(False)
body_range.goRight(pre_len, False)
body_range.gotoEnd(True)
# Plain text (and HTML-import fallback) get the role tint; successful HTML import
# keeps per-span CharColor from the filter (red/blue runs, etc.).
if not used_html_import:
body_range.CharColor = theme.user_color if role == "user" else theme.assistant_color
importer.tighten_list_indent(body_range)
except Exception as e:
log.exception("Error in append_rich_text: %s", e)
def finalize_sidebar_assistant_response(listener) -> None:
"""Re-import the last assistant message as HTML when rich sidebar is active."""
listener.rerender_rich_text_session()
stripper = getattr(listener, "_plain_text_stripper", None)
if stripper is not None:
leftover = stripper.finalize()
listener._plain_text_stripper = None
if leftover and getattr(listener, "rich_text_widget", None) is None:
listener._append_response(leftover, role="assistant")