From 23b5e6f46b8d81310cc7134e44449f66fc3a1a6c Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 2 Oct 2025 08:29:31 +0200 Subject: [PATCH 01/99] pdf: basic pdf writer converted from tdewolff canvas, working for paths --- core/completer.go | 6 +- core/enumgen.go | 43 ++ core/events.go | 14 +- core/filepicker.go | 6 +- core/frame.go | 8 +- core/inlinelist.go | 2 +- core/popupstage.go | 2 +- core/renderwindow.go | 2 +- core/scroll.go | 2 +- core/settings.go | 183 ++++---- core/snackbar.go | 2 +- core/tabs.go | 2 +- core/textcursor.go | 2 +- core/typegen.go | 8 +- core/valuer.go | 6 +- go.mod | 1 + go.sum | 2 + paint/ppath/pdf/font.go | 250 ++++++++++ paint/ppath/pdf/page.go | 618 +++++++++++++++++++++++++ paint/ppath/pdf/pdf.go | 263 +++++++++++ paint/ppath/pdf/pdf_test.go | 84 ++++ paint/ppath/pdf/writer.go | 491 ++++++++++++++++++++ paint/renderers/htmlcanvas/path.go | 11 +- paint/renderers/pdfrender/pdfrender.go | 224 +++++++++ text/rich/style.go | 2 +- 25 files changed, 2119 insertions(+), 115 deletions(-) create mode 100644 paint/ppath/pdf/font.go create mode 100644 paint/ppath/pdf/page.go create mode 100644 paint/ppath/pdf/pdf.go create mode 100644 paint/ppath/pdf/pdf_test.go create mode 100644 paint/ppath/pdf/writer.go create mode 100644 paint/renderers/pdfrender/pdfrender.go diff --git a/core/completer.go b/core/completer.go index a62761be48..0870fc4022 100644 --- a/core/completer.go +++ b/core/completer.go @@ -80,7 +80,7 @@ func (c *Complete) Show(ctx Widget, pos image.Point, text string) { if c.MatchFunc == nil { return } - wait := SystemSettings.CompleteWaitDuration + wait := TimingSettings.CompleteWaitDuration if c.stage != nil { c.Cancel() } @@ -137,8 +137,8 @@ func (c *Complete) showNowImpl(ctx Widget, pos image.Point, text string) bool { if len(c.completions) == 0 { return false } - if len(c.completions) > SystemSettings.CompleteMaxItems { - c.completions = c.completions[0:SystemSettings.CompleteMaxItems] + if len(c.completions) > AppearanceSettings.MenuMax { + c.completions = c.completions[0:AppearanceSettings.MenuMax] } sc := NewScene(ctx.AsTree().Name + "-complete") diff --git a/core/enumgen.go b/core/enumgen.go index 8196e543ad..f1f75e6a0d 100644 --- a/core/enumgen.go +++ b/core/enumgen.go @@ -337,6 +337,49 @@ func (i Themes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } // UnmarshalText implements the [encoding.TextUnmarshaler] interface. func (i *Themes) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Themes") } +var _PageSizesValues = []PageSizes{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + +// PageSizesN is the highest valid value for type PageSizes, plus one. +const PageSizesN PageSizes = 10 + +var _PageSizesValueMap = map[string]PageSizes{`A1`: 0, `A2`: 1, `A3`: 2, `A4`: 3, `A5`: 4, `A6`: 5, `A7`: 6, `Letter`: 7, `Legal`: 8, `Tabloid`: 9} + +var _PageSizesDescMap = map[PageSizes]string{0: ``, 1: ``, 2: ``, 3: ``, 4: ``, 5: ``, 6: ``, 7: ``, 8: ``, 9: ``} + +var _PageSizesMap = map[PageSizes]string{0: `A1`, 1: `A2`, 2: `A3`, 3: `A4`, 4: `A5`, 5: `A6`, 6: `A7`, 7: `Letter`, 8: `Legal`, 9: `Tabloid`} + +// String returns the string representation of this PageSizes value. +func (i PageSizes) String() string { return enums.String(i, _PageSizesMap) } + +// SetString sets the PageSizes value from its string representation, +// and returns an error if the string is invalid. +func (i *PageSizes) SetString(s string) error { + return enums.SetString(i, s, _PageSizesValueMap, "PageSizes") +} + +// Int64 returns the PageSizes value as an int64. +func (i PageSizes) Int64() int64 { return int64(i) } + +// SetInt64 sets the PageSizes value from an int64. +func (i *PageSizes) SetInt64(in int64) { *i = PageSizes(in) } + +// Desc returns the description of the PageSizes value. +func (i PageSizes) Desc() string { return enums.Desc(i, _PageSizesDescMap) } + +// PageSizesValues returns all possible values for the type PageSizes. +func PageSizesValues() []PageSizes { return _PageSizesValues } + +// Values returns all possible values for the type PageSizes. +func (i PageSizes) Values() []enums.Enum { return enums.Values(_PageSizesValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i PageSizes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *PageSizes) UnmarshalText(text []byte) error { + return enums.UnmarshalText(i, text, "PageSizes") +} + var _SizeClassesValues = []SizeClasses{0, 1, 2} // SizeClassesN is the highest valid value for type SizeClasses, plus one. diff --git a/core/events.go b/core/events.go index 131d9b4312..67fa924bec 100644 --- a/core/events.go +++ b/core/events.go @@ -275,7 +275,7 @@ func (em *Events) handlePosEvent(e events.Event) { } case events.Scroll: if !tree.IsNil(em.scroll) { - scInTime := time.Since(em.lastScrollTime) < DeviceSettings.ScrollFocusTime + scInTime := time.Since(em.lastScrollTime) < TimingSettings.ScrollFocusTime if scInTime { em.scroll.AsWidget().HandleEvent(e) if e.IsHandled() { @@ -439,12 +439,12 @@ func (em *Events) handlePosEvent(e events.Event) { em.drag.AsWidget().Send(events.DragMove, e) // usually ignored e.SetHandled() } else { - if !tree.IsNil(em.dragPress) && em.dragStartCheck(e, DeviceSettings.DragStartTime, DeviceSettings.DragStartDistance) { + if !tree.IsNil(em.dragPress) && em.dragStartCheck(e, TimingSettings.DragStartTime, TimingSettings.DragStartDistance) { em.cancelRepeatClick() em.cancelLongPress() em.dragPress.AsWidget().Send(events.DragStart, e) e.SetHandled() - } else if !tree.IsNil(em.slidePress) && em.dragStartCheck(e, DeviceSettings.SlideStartTime, DeviceSettings.DragStartDistance) { + } else if !tree.IsNil(em.slidePress) && em.dragStartCheck(e, TimingSettings.SlideStartTime, TimingSettings.DragStartDistance) { em.cancelRepeatClick() em.cancelLongPress() em.slide = em.slidePress @@ -479,7 +479,7 @@ func (em *Events) handlePosEvent(e events.Event) { em.setCursorFromStyle() return } - dcInTime := time.Since(em.lastClickTime) < DeviceSettings.DoubleClickInterval + dcInTime := time.Since(em.lastClickTime) < TimingSettings.DoubleClickInterval em.lastClickTime = time.Now() sentMulti := false switch { @@ -594,12 +594,12 @@ func (em *Events) topLongHover() Widget { // handleLongHover handles long hover events func (em *Events) handleLongHover(e events.Event) { - em.handleLong(e, em.topLongHover(), &em.longHoverWidget, &em.longHoverPos, &em.longHoverTimer, events.LongHoverStart, events.LongHoverEnd, DeviceSettings.LongHoverTime, DeviceSettings.LongHoverStopDistance) + em.handleLong(e, em.topLongHover(), &em.longHoverWidget, &em.longHoverPos, &em.longHoverTimer, events.LongHoverStart, events.LongHoverEnd, TimingSettings.LongHoverTime, TimingSettings.LongHoverStopDistance) } // handleLongPress handles long press events func (em *Events) handleLongPress(e events.Event) { - em.handleLong(e, em.press, &em.longPressWidget, &em.longPressPos, &em.longPressTimer, events.LongPressStart, events.LongPressEnd, DeviceSettings.LongPressTime, DeviceSettings.LongPressStopDistance) + em.handleLong(e, em.press, &em.longPressWidget, &em.longPressPos, &em.longPressTimer, events.LongPressStart, events.LongPressEnd, TimingSettings.LongPressTime, TimingSettings.LongPressStopDistance) } // handleLong is the implementation of [Events.handleLongHover] and @@ -759,7 +759,7 @@ func (em *Events) startRepeatClickTimer() { if tree.IsNil(em.repeatClick) || !em.repeatClick.AsWidget().IsVisible() { return } - delay := DeviceSettings.RepeatClickTime + delay := TimingSettings.RepeatClickTime if em.repeatClickTimer == nil { delay *= 8 } diff --git a/core/filepicker.go b/core/filepicker.go index f1619f7d77..7c531961ce 100644 --- a/core/filepicker.go +++ b/core/filepicker.go @@ -309,8 +309,8 @@ func (fp *FilePicker) makeFilesRow(p *tree.Plan) { w.SetSlice(&fp.files) w.SelectedField = "Name" w.SelectedValue = fp.selectedFilename - if SystemSettings.FilePickerSort != "" { - w.setSortFieldName(SystemSettings.FilePickerSort) + if AppearanceSettings.FilePickerSort != "" { + w.setSortFieldName(AppearanceSettings.FilePickerSort) } w.TableStyler = func(w Widget, s *styles.Style, row, col int) { fn := fp.files[row].Name @@ -657,7 +657,7 @@ func (fp *FilePicker) saveSortSettings() { if sv == nil { return } - SystemSettings.FilePickerSort = sv.sortFieldName() + AppearanceSettings.FilePickerSort = sv.sortFieldName() // fmt.Printf("sort: %v\n", Settings.FilePickerSort) ErrorSnackbar(fp, SaveSettings(SystemSettings), "Error saving settings") } diff --git a/core/frame.go b/core/frame.go index ef4bb83b4a..e690931772 100644 --- a/core/frame.go +++ b/core/frame.go @@ -131,7 +131,7 @@ func (fr *Frame) Init() { return case keymap.PageDown: proc := false - for st := 0; st < SystemSettings.LayoutPageSteps; st++ { + for st := 0; st < TimingSettings.LayoutPageSteps; st++ { if !fr.focusNextChild(true) { break } @@ -143,7 +143,7 @@ func (fr *Frame) Init() { return case keymap.PageUp: proc := false - for st := 0; st < SystemSettings.LayoutPageSteps; st++ { + for st := 0; st < TimingSettings.LayoutPageSteps; st++ { if !fr.focusPreviousChild(true) { break } @@ -381,13 +381,13 @@ func (fr *Frame) focusOnName(e events.Event) bool { delay := e.Time().Sub(fr.focusNameTime) fr.focusNameTime = e.Time() if kf == keymap.FocusNext { // tab means go to next match -- don't worry about time - if fr.focusName == "" || delay > SystemSettings.LayoutFocusNameTabTime { + if fr.focusName == "" || delay > TimingSettings.LayoutFocusNameTabTime { fr.focusName = "" fr.focusNameLast = nil return false } } else { - if delay > SystemSettings.LayoutFocusNameTimeout { + if delay > TimingSettings.LayoutFocusNameTimeout { fr.focusName = "" } if !unicode.IsPrint(e.KeyRune()) || e.Modifiers() != 0 { diff --git a/core/inlinelist.go b/core/inlinelist.go index 671cbabfa2..f1c92ee89c 100644 --- a/core/inlinelist.go +++ b/core/inlinelist.go @@ -33,7 +33,7 @@ func (il *InlineList) Init() { il.Maker(func(p *tree.Plan) { sl := reflectx.Underlying(reflect.ValueOf(il.Slice)) - sz := min(sl.Len(), SystemSettings.SliceInlineLength) + sz := min(sl.Len(), AppearanceSettings.InlineLengths.Slice) for i := 0; i < sz; i++ { itxt := strconv.Itoa(i) tree.AddNew(p, "value-"+itxt, func() Value { diff --git a/core/popupstage.go b/core/popupstage.go index 64403a4e3b..cc44a99960 100644 --- a/core/popupstage.go +++ b/core/popupstage.go @@ -101,7 +101,7 @@ func (st *Stage) runPopup() *Stage { switch st.Type { case MenuStage: sz.X += scrollWd * 2 - maxht := int(float32(SystemSettings.MenuMaxHeight) * fontHt) + maxht := int(float32(AppearanceSettings.MenuMax) * fontHt) sz.Y = min(maxht, sz.Y) case SnackbarStage: b := msc.SceneGeom.Bounds() diff --git a/core/renderwindow.go b/core/renderwindow.go index 10b31f935b..76a2fcf8be 100644 --- a/core/renderwindow.go +++ b/core/renderwindow.go @@ -686,7 +686,7 @@ func (w *renderWindow) renderWindow() { } spriteMods := top.Sprites.IsModified() - spriteUpdateTime := SystemSettings.CursorBlinkTime + spriteUpdateTime := TimingSettings.CursorBlinkTime if spriteUpdateTime == 0 { spriteUpdateTime = 500 * time.Millisecond } diff --git a/core/scroll.go b/core/scroll.go index c593ef4c3d..1bb82b41c6 100644 --- a/core/scroll.go +++ b/core/scroll.go @@ -348,7 +348,7 @@ var lastAutoScroll time.Time func (fr *Frame) AutoScroll(pos math32.Vector2) bool { now := time.Now() lag := now.Sub(lastAutoScroll) - if lag < SystemSettings.LayoutAutoScrollDelay { + if lag < TimingSettings.LayoutAutoScrollDelay { return false } did := false diff --git a/core/settings.go b/core/settings.go index 931923a734..2a254facf6 100644 --- a/core/settings.go +++ b/core/settings.go @@ -36,7 +36,7 @@ import ( // that the user will see in the settings window. It contains the base Cogent Core // settings by default and should be modified by other apps to add their // app settings. -var AllSettings = []Settings{AppearanceSettings, SystemSettings, DeviceSettings, DebugSettings} +var AllSettings = []Settings{AppearanceSettings, SystemSettings, TimingSettings, DebugSettings} // Settings is the interface that describes the functionality common // to all settings data types. @@ -271,12 +271,43 @@ type AppearanceSettingsData struct { //types:add // Text specifies text settings including the language, and the // font families for different styles of fonts. Text rich.Settings + + // only support closing the currently selected active tab; + // if this is set to true, pressing the close button on other tabs + // will take you to that tab, from which you can close it. + OnlyCloseActiveTab bool `default:"false"` + + // the maximum number of items in a menu popup panel; + // scroll bars are enforced beyond that size, or for + // completion, this is the max number of items shown. + MenuMax int `default:"30" min:"5" step:"1"` + + // column to sort by in FilePicker, and :up or :down for direction. + // Updated automatically via FilePicker + FilePickerSort string `display:"-"` + + // length of inline elements to display for containers in Form widgets. + InlineLengths InlineLengths `display:"inline"` } func (as *AppearanceSettingsData) Defaults() { as.Text.Defaults() } +// InlineLengths has the length of inline elements to display for containers in Form widgets. +type InlineLengths struct { + // the number of map elements at or below which an inline representation + // of the map will be presented, which is more convenient for small #'s of properties + Map int `default:"2" min:"1" step:"1"` + + // the number of elemental struct fields at or below which an inline representation + // of the struct will be presented, which is more convenient for small structs + Struct int `default:"4" min:"2" step:"1"` + + // the number of slice elements below which inline will be used + Slice int `default:"4" min:"2" step:"1"` +} + // ConstantSpacing returns a spacing value (padding, margin, gap) // that will remain constant regardless of changes in the // [AppearanceSettings.Spacing] setting. @@ -384,11 +415,11 @@ func (as *AppearanceSettingsData) ZebraStripesWeight() float32 { return as.ZebraStripes * 0.002 } -// DeviceSettings are the global device settings. -var DeviceSettings = &DeviceSettingsData{ +// TimingSettings are the global timing settings. +var TimingSettings = &TimingSettingsData{ SettingsBase: SettingsBase{ - Name: "Device", - File: filepath.Join(TheApp.CogentCoreDataDir(), "device-settings.toml"), + Name: "Timing", + File: filepath.Join(TheApp.CogentCoreDataDir(), "timing-settings.toml"), }, } @@ -409,17 +440,14 @@ func (as *AppearanceSettingsData) SaveScreenZoom() { //types:add errors.Log(SaveSettings(as)) } -// DeviceSettingsData is the data type for the device settings. -type DeviceSettingsData struct { //types:add +// TimingSettingsData is the data type for the timing settings. +type TimingSettingsData struct { //types:add SettingsBase - // The keyboard shortcut map to use - KeyMap keymap.MapName - - // The keyboard shortcut maps available as options for Key map. - // If you do not want to have custom key maps, you should leave - // this unset so that you always have the latest standard key maps. - KeyMaps option.Option[keymap.Maps] + // SnackbarTimeout is the default amount of time until snackbars + // disappear (snackbars show short updates about app processes + // at the bottom of the screen) + SnackbarTimeout time.Duration `default:"5s"` // The maximum time interval between button press events to count as a double-click DoubleClickInterval time.Duration `default:"500ms" min:"100ms" step:"50ms"` @@ -461,22 +489,33 @@ type DeviceSettingsData struct { //types:add // The maximum number of pixels that mouse/finger can move and still register a long press event LongPressStopDistance int `default:"50" min:"0" max:"1000" step:"1"` -} -func (ds *DeviceSettingsData) Defaults() { - ds.KeyMap = keymap.DefaultMap - ds.KeyMaps.Value = keymap.AvailableMaps + // the amount of time to wait before offering completions + CompleteWaitDuration time.Duration `default:"0ms" min:"0ms" max:"10s" step:"10ms"` + + // time interval for cursor blinking on and off -- set to 0 to disable blinking + CursorBlinkTime time.Duration `default:"500ms" min:"0ms" max:"1s" step:"5ms"` + + // The amount of time to wait before trying to autoscroll again + LayoutAutoScrollDelay time.Duration `default:"25ms" min:"1ms" step:"5ms"` + + // number of steps to take in PageUp / Down events in terms of number of items + LayoutPageSteps int `default:"10" min:"1" step:"1"` + + // the amount of time between keypresses to combine characters into name + // to search for within layout -- starts over after this delay. + LayoutFocusNameTimeout time.Duration `default:"500ms" min:"0ms" max:"5s" step:"20ms"` + + // the amount of time since last focus name event to allow tab to focus + // on next element with same name. + LayoutFocusNameTabTime time.Duration `default:"2s" min:"10ms" max:"10s" step:"100ms"` } -func (ds *DeviceSettingsData) Apply() { - if ds.KeyMaps.Valid { - keymap.AvailableMaps = ds.KeyMaps.Value - } - if ds.KeyMap != "" { - keymap.SetActiveMapName(ds.KeyMap) - } +func (ts *TimingSettingsData) Defaults() { +} - events.ScrollWheelSpeed = ds.ScrollWheelSpeed +func (ts *TimingSettingsData) Apply() { + events.ScrollWheelSpeed = ts.ScrollWheelSpeed } // ScreenSettings are per-screen settings that override the global settings. @@ -498,21 +537,26 @@ var SystemSettings = &SystemSettingsData{ type SystemSettingsData struct { //types:add SettingsBase + // The keyboard shortcut map to use + KeyMap keymap.MapName + + // The keyboard shortcut maps available as options for Key map. + // If you do not want to have custom key maps, you should leave + // this unset so that you always have the latest standard key maps. + KeyMaps option.Option[keymap.Maps] + // text editor settings Editor text.EditorSettings // whether to use a 24-hour clock (instead of AM and PM) Clock24 bool `label:"24-hour clock"` - // SnackbarTimeout is the default amount of time until snackbars - // disappear (snackbars show short updates about app processes - // at the bottom of the screen) - SnackbarTimeout time.Duration `default:"5s"` + // default page size for PDF generation, typically either Letter or A4. + PageSize PageSizes - // only support closing the currently selected active tab; - // if this is set to true, pressing the close button on other tabs - // will take you to that tab, from which you can close it. - OnlyCloseActiveTab bool `default:"false"` + // user info, which is partially filled-out automatically if empty + // when settings are first created. + User User // the limit of file size, above which user will be prompted before // opening / copying, etc. @@ -521,63 +565,26 @@ type SystemSettingsData struct { //types:add // maximum number of saved paths to save in FilePicker SavedPathsMax int `default:"50"` - // user info, which is partially filled-out automatically if empty - // when settings are first created. - User User - // favorite paths, shown in FilePickerer and also editable there FavPaths favoritePaths - - // column to sort by in FilePicker, and :up or :down for direction. - // Updated automatically via FilePicker - FilePickerSort string `display:"-"` - - // the maximum height of any menu popup panel in units of font height; - // scroll bars are enforced beyond that size. - MenuMaxHeight int `default:"30" min:"5" step:"1"` - - // the amount of time to wait before offering completions - CompleteWaitDuration time.Duration `default:"0ms" min:"0ms" max:"10s" step:"10ms"` - - // the maximum number of completions offered in popup - CompleteMaxItems int `default:"25" min:"5" step:"1"` - - // time interval for cursor blinking on and off -- set to 0 to disable blinking - CursorBlinkTime time.Duration `default:"500ms" min:"0ms" max:"1s" step:"5ms"` - - // The amount of time to wait before trying to autoscroll again - LayoutAutoScrollDelay time.Duration `default:"25ms" min:"1ms" step:"5ms"` - - // number of steps to take in PageUp / Down events in terms of number of items - LayoutPageSteps int `default:"10" min:"1" step:"1"` - - // the amount of time between keypresses to combine characters into name - // to search for within layout -- starts over after this delay. - LayoutFocusNameTimeout time.Duration `default:"500ms" min:"0ms" max:"5s" step:"20ms"` - - // the amount of time since last focus name event to allow tab to focus - // on next element with same name. - LayoutFocusNameTabTime time.Duration `default:"2s" min:"10ms" max:"10s" step:"100ms"` - - // the number of map elements at or below which an inline representation - // of the map will be presented, which is more convenient for small #'s of properties - MapInlineLength int `default:"2" min:"1" step:"1"` - - // the number of elemental struct fields at or below which an inline representation - // of the struct will be presented, which is more convenient for small structs - StructInlineLength int `default:"4" min:"2" step:"1"` - - // the number of slice elements below which inline will be used - SliceInlineLength int `default:"4" min:"2" step:"1"` } func (ss *SystemSettingsData) Defaults() { + ss.KeyMap = keymap.DefaultMap + ss.KeyMaps.Value = keymap.AvailableMaps ss.FavPaths.setToDefaults() + ss.PageSize = Letter ss.updateUser() } // Apply detailed settings to all the relevant settings. func (ss *SystemSettingsData) Apply() { //types:add + if ss.KeyMaps.Valid { + keymap.AvailableMaps = ss.KeyMaps.Value + } + if ss.KeyMap != "" { + keymap.SetActiveMapName(ss.KeyMap) + } np := len(ss.FavPaths) for i := 0; i < np; i++ { if ss.FavPaths[i].Icon == "" || ss.FavPaths[i].Icon == "folder" { @@ -621,6 +628,22 @@ type User struct { //types:add Email string } +// PageSizes are the different possible page sizes that a user can select in their settings. +type PageSizes int32 //enums:enum + +const ( + A1 PageSizes = iota + A2 + A3 + A4 + A5 + A6 + A7 + Letter + Legal + Tabloid +) + //////// FavoritePaths // favoritePathItem represents one item in a favorite path list, for display of diff --git a/core/snackbar.go b/core/snackbar.go index 3f4accdd96..5dac0237ce 100644 --- a/core/snackbar.go +++ b/core/snackbar.go @@ -30,7 +30,7 @@ func (bd *Body) NewSnackbar(ctx Widget) *Stage { ctx = nonNilContext(ctx) bd.snackbarStyles() bd.Scene.Stage = NewPopupStage(SnackbarStage, bd.Scene, ctx). - SetTimeout(SystemSettings.SnackbarTimeout) + SetTimeout(TimingSettings.SnackbarTimeout) return bd.Scene.Stage } diff --git a/core/tabs.go b/core/tabs.go index 5e34e56d12..2ec2d54db2 100644 --- a/core/tabs.go +++ b/core/tabs.go @@ -534,7 +534,7 @@ func (tb *Tab) Init() { ts := tb.tabs() idx := ts.tabIndexByName(tb.Name) // if OnlyCloseActiveTab is on, only process delete when already selected - if SystemSettings.OnlyCloseActiveTab && !tb.StateIs(states.Selected) { + if AppearanceSettings.OnlyCloseActiveTab && !tb.StateIs(states.Selected) { ts.SelectTabIndex(idx) } else { ts.DeleteTabIndex(idx) diff --git a/core/textcursor.go b/core/textcursor.go index 3e2dad727b..05f86255e0 100644 --- a/core/textcursor.go +++ b/core/textcursor.go @@ -63,7 +63,7 @@ func TextCursor(on bool, w *WidgetBase, lastW *tree.Node, name string, width, he if !turnOn { isOn := sp.Properties["on"].(bool) lastSwitch := sp.Properties["lastSwitch"].(time.Time) - if TheApp.Platform() != system.Offscreen && SystemSettings.CursorBlinkTime > 0 && time.Since(lastSwitch) > SystemSettings.CursorBlinkTime { + if TheApp.Platform() != system.Offscreen && TimingSettings.CursorBlinkTime > 0 && time.Since(lastSwitch) > TimingSettings.CursorBlinkTime { isOn = !isOn sp.Properties["on"] = isOn sp.Properties["lastSwitch"] = time.Now() diff --git a/core/typegen.go b/core/typegen.go index 06ca9383b7..793227d308 100644 --- a/core/typegen.go +++ b/core/typegen.go @@ -620,13 +620,13 @@ var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Separator", ID // specified by [styles.Style.Direction]. func NewSeparator(parent ...tree.Node) *Separator { return tree.New[Separator](parent...) } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.AppearanceSettingsData", IDName: "appearance-settings-data", Doc: "AppearanceSettingsData is the data type for the global Cogent Core appearance settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "deleteSavedWindowGeometries", Doc: "deleteSavedWindowGeometries deletes the file that saves the position and size of\neach window, by screen, and clear current in-memory cache. You shouldn't generally\nneed to do this, but sometimes it is useful for testing or windows that are\nshowing up in bad places that you can't recover from.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "SaveScreenZoom", Doc: "SaveScreenZoom saves the current zoom factor for the current screen,\nwhich will then be used for this screen instead of overall default.\nUse the Control +/- keyboard shortcut to modify the screen zoom level.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "Theme", Doc: "the color theme."}, {Name: "Color", Doc: "the primary color used to generate the color scheme."}, {Name: "Zoom", Doc: "overall zoom factor as a percentage of the default zoom.\nUse Control +/- keyboard shortcut to change zoom level anytime.\nScreen-specific zoom factor will be used if present, see 'Screens' field."}, {Name: "Spacing", Doc: "the overall spacing factor as a percentage of the default amount of spacing\n(higher numbers lead to more space and lower numbers lead to higher density)."}, {Name: "FontSize", Doc: "the overall font size factor applied to all text as a percentage\nof the default font size (higher numbers lead to larger text)."}, {Name: "DocsFontSize", Doc: "Font size factor applied only to documentation and other\ndense text contexts, not normal interactive elements.\nIt is a percentage of the base Font size setting (higher numbers\nlead to larger text)."}, {Name: "ZebraStripes", Doc: "the amount that alternating rows are highlighted when showing\ntabular data (set to 0 to disable zebra striping)."}, {Name: "Screens", Doc: "screen-specific settings, which will override overall defaults if set,\nso different screens can use different zoom levels.\nUse 'Save screen zoom' in the toolbar to save the current zoom for the current\nscreen, and Control +/- keyboard shortcut to change this zoom level anytime."}, {Name: "Highlighting", Doc: "text highlighting style / theme."}, {Name: "Text", Doc: "Text specifies text settings including the language, and the\nfont families for different styles of fonts."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.AppearanceSettingsData", IDName: "appearance-settings-data", Doc: "AppearanceSettingsData is the data type for the global Cogent Core appearance settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "deleteSavedWindowGeometries", Doc: "deleteSavedWindowGeometries deletes the file that saves the position and size of\neach window, by screen, and clear current in-memory cache. You shouldn't generally\nneed to do this, but sometimes it is useful for testing or windows that are\nshowing up in bad places that you can't recover from.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "SaveScreenZoom", Doc: "SaveScreenZoom saves the current zoom factor for the current screen,\nwhich will then be used for this screen instead of overall default.\nUse the Control +/- keyboard shortcut to modify the screen zoom level.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "Theme", Doc: "the color theme."}, {Name: "Color", Doc: "the primary color used to generate the color scheme."}, {Name: "Zoom", Doc: "overall zoom factor as a percentage of the default zoom.\nUse Control +/- keyboard shortcut to change zoom level anytime.\nScreen-specific zoom factor will be used if present, see 'Screens' field."}, {Name: "Spacing", Doc: "the overall spacing factor as a percentage of the default amount of spacing\n(higher numbers lead to more space and lower numbers lead to higher density)."}, {Name: "FontSize", Doc: "the overall font size factor applied to all text as a percentage\nof the default font size (higher numbers lead to larger text)."}, {Name: "DocsFontSize", Doc: "Font size factor applied only to documentation and other\ndense text contexts, not normal interactive elements.\nIt is a percentage of the base Font size setting (higher numbers\nlead to larger text)."}, {Name: "ZebraStripes", Doc: "the amount that alternating rows are highlighted when showing\ntabular data (set to 0 to disable zebra striping)."}, {Name: "Screens", Doc: "screen-specific settings, which will override overall defaults if set,\nso different screens can use different zoom levels.\nUse 'Save screen zoom' in the toolbar to save the current zoom for the current\nscreen, and Control +/- keyboard shortcut to change this zoom level anytime."}, {Name: "Highlighting", Doc: "text highlighting style / theme."}, {Name: "Text", Doc: "Text specifies text settings including the language, and the\nfont families for different styles of fonts."}, {Name: "OnlyCloseActiveTab", Doc: "only support closing the currently selected active tab;\nif this is set to true, pressing the close button on other tabs\nwill take you to that tab, from which you can close it."}, {Name: "MenuMax", Doc: "the maximum number of items in a menu popup panel;\nscroll bars are enforced beyond that size, or for\ncompletion, this is the max number of items shown."}, {Name: "FilePickerSort", Doc: "column to sort by in FilePicker, and :up or :down for direction.\nUpdated automatically via FilePicker"}, {Name: "InlineLengths", Doc: "length of inline elements to display for containers in Form widgets."}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.DeviceSettingsData", IDName: "device-settings-data", Doc: "DeviceSettingsData is the data type for the device settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "KeyMap", Doc: "The keyboard shortcut map to use"}, {Name: "KeyMaps", Doc: "The keyboard shortcut maps available as options for Key map.\nIf you do not want to have custom key maps, you should leave\nthis unset so that you always have the latest standard key maps."}, {Name: "DoubleClickInterval", Doc: "The maximum time interval between button press events to count as a double-click"}, {Name: "ScrollWheelSpeed", Doc: "How fast the scroll wheel moves, which is typically pixels per wheel step\nbut units can be arbitrary. It is generally impossible to standardize speed\nand variable across devices, and we don't have access to the system settings,\nso unfortunately you have to set it here."}, {Name: "ScrollFocusTime", Doc: "The duration over which the current scroll widget retains scroll focus,\nsuch that subsequent scroll events are sent to it."}, {Name: "SlideStartTime", Doc: "The amount of time to wait before initiating a slide event\n(as opposed to a basic press event)"}, {Name: "DragStartTime", Doc: "The amount of time to wait before initiating a drag (drag and drop) event\n(as opposed to a basic press or slide event)"}, {Name: "RepeatClickTime", Doc: "The amount of time to wait between each repeat click event,\nwhen the mouse is pressed down. The first click is 8x this."}, {Name: "DragStartDistance", Doc: "The number of pixels that must be moved before initiating a slide/drag\nevent (as opposed to a basic press event)"}, {Name: "LongHoverTime", Doc: "The amount of time to wait before initiating a long hover event (e.g., for opening a tooltip)"}, {Name: "LongHoverStopDistance", Doc: "The maximum number of pixels that mouse can move and still register a long hover event"}, {Name: "LongPressTime", Doc: "The amount of time to wait before initiating a long press event (e.g., for opening a tooltip)"}, {Name: "LongPressStopDistance", Doc: "The maximum number of pixels that mouse/finger can move and still register a long press event"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.TimingSettingsData", IDName: "timing-settings-data", Doc: "TimingSettingsData is the data type for the timing settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "SnackbarTimeout", Doc: "SnackbarTimeout is the default amount of time until snackbars\ndisappear (snackbars show short updates about app processes\nat the bottom of the screen)"}, {Name: "DoubleClickInterval", Doc: "The maximum time interval between button press events to count as a double-click"}, {Name: "ScrollWheelSpeed", Doc: "How fast the scroll wheel moves, which is typically pixels per wheel step\nbut units can be arbitrary. It is generally impossible to standardize speed\nand variable across devices, and we don't have access to the system settings,\nso unfortunately you have to set it here."}, {Name: "ScrollFocusTime", Doc: "The duration over which the current scroll widget retains scroll focus,\nsuch that subsequent scroll events are sent to it."}, {Name: "SlideStartTime", Doc: "The amount of time to wait before initiating a slide event\n(as opposed to a basic press event)"}, {Name: "DragStartTime", Doc: "The amount of time to wait before initiating a drag (drag and drop) event\n(as opposed to a basic press or slide event)"}, {Name: "RepeatClickTime", Doc: "The amount of time to wait between each repeat click event,\nwhen the mouse is pressed down. The first click is 8x this."}, {Name: "DragStartDistance", Doc: "The number of pixels that must be moved before initiating a slide/drag\nevent (as opposed to a basic press event)"}, {Name: "LongHoverTime", Doc: "The amount of time to wait before initiating a long hover event (e.g., for opening a tooltip)"}, {Name: "LongHoverStopDistance", Doc: "The maximum number of pixels that mouse can move and still register a long hover event"}, {Name: "LongPressTime", Doc: "The amount of time to wait before initiating a long press event (e.g., for opening a tooltip)"}, {Name: "LongPressStopDistance", Doc: "The maximum number of pixels that mouse/finger can move and still register a long press event"}, {Name: "CompleteWaitDuration", Doc: "the amount of time to wait before offering completions"}, {Name: "CursorBlinkTime", Doc: "time interval for cursor blinking on and off -- set to 0 to disable blinking"}, {Name: "LayoutAutoScrollDelay", Doc: "The amount of time to wait before trying to autoscroll again"}, {Name: "LayoutPageSteps", Doc: "number of steps to take in PageUp / Down events in terms of number of items"}, {Name: "LayoutFocusNameTimeout", Doc: "the amount of time between keypresses to combine characters into name\nto search for within layout -- starts over after this delay."}, {Name: "LayoutFocusNameTabTime", Doc: "the amount of time since last focus name event to allow tab to focus\non next element with same name."}}}) var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.ScreenSettings", IDName: "screen-settings", Doc: "ScreenSettings are per-screen settings that override the global settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Zoom", Doc: "overall zoom factor as a percentage of the default zoom"}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.SystemSettingsData", IDName: "system-settings-data", Doc: "SystemSettingsData is the data type of the global Cogent Core settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Doc: "Apply detailed settings to all the relevant settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "Editor", Doc: "text editor settings"}, {Name: "Clock24", Doc: "whether to use a 24-hour clock (instead of AM and PM)"}, {Name: "SnackbarTimeout", Doc: "SnackbarTimeout is the default amount of time until snackbars\ndisappear (snackbars show short updates about app processes\nat the bottom of the screen)"}, {Name: "OnlyCloseActiveTab", Doc: "only support closing the currently selected active tab;\nif this is set to true, pressing the close button on other tabs\nwill take you to that tab, from which you can close it."}, {Name: "BigFileSize", Doc: "the limit of file size, above which user will be prompted before\nopening / copying, etc."}, {Name: "SavedPathsMax", Doc: "maximum number of saved paths to save in FilePicker"}, {Name: "User", Doc: "user info, which is partially filled-out automatically if empty\nwhen settings are first created."}, {Name: "FavPaths", Doc: "favorite paths, shown in FilePickerer and also editable there"}, {Name: "FilePickerSort", Doc: "column to sort by in FilePicker, and :up or :down for direction.\nUpdated automatically via FilePicker"}, {Name: "MenuMaxHeight", Doc: "the maximum height of any menu popup panel in units of font height;\nscroll bars are enforced beyond that size."}, {Name: "CompleteWaitDuration", Doc: "the amount of time to wait before offering completions"}, {Name: "CompleteMaxItems", Doc: "the maximum number of completions offered in popup"}, {Name: "CursorBlinkTime", Doc: "time interval for cursor blinking on and off -- set to 0 to disable blinking"}, {Name: "LayoutAutoScrollDelay", Doc: "The amount of time to wait before trying to autoscroll again"}, {Name: "LayoutPageSteps", Doc: "number of steps to take in PageUp / Down events in terms of number of items"}, {Name: "LayoutFocusNameTimeout", Doc: "the amount of time between keypresses to combine characters into name\nto search for within layout -- starts over after this delay."}, {Name: "LayoutFocusNameTabTime", Doc: "the amount of time since last focus name event to allow tab to focus\non next element with same name."}, {Name: "MapInlineLength", Doc: "the number of map elements at or below which an inline representation\nof the map will be presented, which is more convenient for small #'s of properties"}, {Name: "StructInlineLength", Doc: "the number of elemental struct fields at or below which an inline representation\nof the struct will be presented, which is more convenient for small structs"}, {Name: "SliceInlineLength", Doc: "the number of slice elements below which inline will be used"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.SystemSettingsData", IDName: "system-settings-data", Doc: "SystemSettingsData is the data type of the global Cogent Core settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Doc: "Apply detailed settings to all the relevant settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "KeyMap", Doc: "The keyboard shortcut map to use"}, {Name: "KeyMaps", Doc: "The keyboard shortcut maps available as options for Key map.\nIf you do not want to have custom key maps, you should leave\nthis unset so that you always have the latest standard key maps."}, {Name: "Editor", Doc: "text editor settings"}, {Name: "Clock24", Doc: "whether to use a 24-hour clock (instead of AM and PM)"}, {Name: "PageSize", Doc: "default page size for PDF generation, typically either Letter or A4."}, {Name: "User", Doc: "user info, which is partially filled-out automatically if empty\nwhen settings are first created."}, {Name: "BigFileSize", Doc: "the limit of file size, above which user will be prompted before\nopening / copying, etc."}, {Name: "SavedPathsMax", Doc: "maximum number of saved paths to save in FilePicker"}, {Name: "FavPaths", Doc: "favorite paths, shown in FilePickerer and also editable there"}}}) var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.User", IDName: "user", Doc: "User basic user information that might be needed for different apps", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "User"}}, Fields: []types.Field{{Name: "Email", Doc: "default email address -- e.g., for recording changes in a version control system"}}}) @@ -1195,7 +1195,7 @@ func NewToolbar(parent ...tree.Node) *Toolbar { return tree.New[Toolbar](parent. var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Treer", IDName: "treer", Doc: "Treer is an interface for [Tree] types\nproviding access to the base [Tree] and\noverridable method hooks for actions taken on the [Tree],\nincluding OnOpen, OnClose, etc.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "AsCoreTree", Doc: "AsTree returns the base [Tree] for this node.", Returns: []string{"Tree"}}, {Name: "CanOpen", Doc: "CanOpen returns true if the node is able to open.\nBy default it checks HasChildren(), but could check other properties\nto perform lazy building of the tree.", Returns: []string{"bool"}}, {Name: "OnOpen", Doc: "OnOpen is called when a node is toggled open.\nThe base version does nothing."}, {Name: "OnClose", Doc: "OnClose is called when a node is toggled closed.\nThe base version does nothing."}, {Name: "MimeData", Args: []string{"md"}}, {Name: "Cut"}, {Name: "Copy"}, {Name: "Paste"}, {Name: "DeleteSelected"}, {Name: "DragDrop", Args: []string{"e"}}, {Name: "DropDeleteSource", Args: []string{"e"}}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Tree", IDName: "tree", Doc: "Tree provides a graphical representation of a tree structure,\nproviding full navigation and manipulation abilities.\n\nIt does not handle layout by itself, so if you want it to scroll\nseparately from the rest of the surrounding context, you must\nplace it in a [Frame].\n\nIf the [Tree.SyncNode] field is non-nil, typically via the\n[Tree.SyncTree] method, then the Tree mirrors another\ntree structure, and tree editing functions apply to\nthe source tree first, and then to the Tree by sync.\n\nOtherwise, data can be directly encoded in a Tree\nderived type, to represent any kind of tree structure\nand associated data.\n\nStandard [events.Event]s are sent to any listeners, including\n[events.Select], [events.Change], and [events.DoubleClick].\nThe selected nodes are in the root [Tree.SelectedNodes] list;\nselect events are sent to both selected nodes and the root node.\nSee [Tree.IsRootSelected] to check whether a select event on the root\nnode corresponds to the root node or another node.", Methods: []types.Method{{Name: "OpenAll", Doc: "OpenAll opens the node and all of its sub-nodes.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "CloseAll", Doc: "CloseAll closes the node and all of its sub-nodes.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "DeleteSelected", Doc: "DeleteSelected deletes selected items.\nMust be called from first node in selection.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Copy", Doc: "Copy copies the selected items to the clipboard.\nThis must be called on the first item in the selected list.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Cut", Doc: "Cut copies to [system.Clipboard] and deletes selected items.\nThis must be called on the first item in the selected list.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Paste", Doc: "Paste pastes clipboard at given node.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "InsertAfter", Doc: "InsertAfter inserts a new node in the tree\nafter this node, at the same (sibling) level,\nprompting for the type of node to insert.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "InsertBefore", Doc: "InsertBefore inserts a new node in the tree\nbefore this node, at the same (sibling) level,\nprompting for the type of node to insert\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "AddChildNode", Doc: "AddChildNode adds a new child node to this one in the tree,\nprompting the user for the type of node to add\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "DeleteNode", Doc: "DeleteNode deletes the tree node or sync node corresponding\nto this view node in the sync tree.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Duplicate", Doc: "Duplicate duplicates the sync node corresponding to this view node in\nthe tree, and inserts the duplicate after this node (as a new sibling).\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "EditNode", Doc: "EditNode pulls up a [Form] dialog for the node.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "inspectNode", Doc: "inspectNode pulls up a new Inspector window on the node.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "SyncNode", Doc: "SyncNode, if non-nil, is the [tree.Node] that this widget is\nviewing in the tree (the source). It should be set using\n[Tree.SyncTree]."}, {Name: "Text", Doc: "Text is the text to display for the tree item label, which automatically\ndefaults to the [tree.Node.Name] of the tree node. It has no effect\nif [Tree.SyncNode] is non-nil."}, {Name: "Icon", Doc: "Icon is an optional icon displayed to the the left of the text label."}, {Name: "IconOpen", Doc: "IconOpen is the icon to use for an open (expanded) branch;\nit defaults to [icons.KeyboardArrowDown]."}, {Name: "IconClosed", Doc: "IconClosed is the icon to use for a closed (collapsed) branch;\nit defaults to [icons.KeyboardArrowRight]."}, {Name: "IconLeaf", Doc: "IconLeaf is the icon to use for a terminal node branch that has no children;\nit defaults to [icons.Blank]."}, {Name: "TreeInit", Doc: "TreeInit is a function that can be set on the root node that is called\nwith each child tree node when it is initialized. It is only\ncalled with the root node itself in [Tree.SetTreeInit], so you\nshould typically call that instead of setting this directly."}, {Name: "Indent", Doc: "Indent is the amount to indent children relative to this node.\nIt should be set in a Styler like all other style properties."}, {Name: "OpenDepth", Doc: "OpenDepth is the depth for nodes be initialized as open (default 4).\nNodes beyond this depth will be initialized as closed."}, {Name: "Closed", Doc: "Closed is whether this tree node is currently toggled closed\n(children not visible)."}, {Name: "SelectMode", Doc: "SelectMode, when set on the root node, determines whether keyboard movements should update selection."}, {Name: "viewIndex", Doc: "linear index of this node within the entire tree.\nupdated on full rebuilds and may sometimes be off,\nbut close enough for expected uses"}, {Name: "widgetSize", Doc: "size of just this node widget.\nour alloc includes all of our children, but we only draw us."}, {Name: "Root", Doc: "Root is the cached root of the tree. It is automatically set."}, {Name: "SelectedNodes", Doc: "SelectedNodes holds the currently selected nodes.\nIt is only set on the root node. See [Tree.GetSelectedNodes]\nfor a version that also works on non-root nodes."}, {Name: "actStateLayer", Doc: "actStateLayer is the actual state layer of the tree, which\nshould be used when rendering it and its parts (but not its children).\nthe reason that it exists is so that the children of the tree\n(other trees) do not inherit its stateful background color, as\nthat does not look good."}, {Name: "inOpen", Doc: "inOpen is set in the Open method to prevent recursive opening for lazy-open nodes."}, {Name: "Branch", Doc: "Branch is the branch widget that is used to open and close the tree node."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Tree", IDName: "tree", Doc: "Tree provides a graphical representation of a tree structure,\nproviding full navigation and manipulation abilities.\n\nIt does not handle layout by itself, so if you want it to scroll\nseparately from the rest of the surrounding context, you must\nplace it in a [Frame].\n\nIf the [Tree.SyncNode] field is non-nil, typically via the\n[Tree.SyncTree] method, then the Tree mirrors another\ntree structure, and tree editing functions apply to\nthe source tree first, and then to the Tree by sync.\n\nOtherwise, data can be directly encoded in a Tree\nderived type, to represent any kind of tree structure\nand associated data.\n\nStandard [events.Event]s are sent to any listeners, including\n[events.Select], [events.Change], and [events.DoubleClick].\nThe selected nodes are in the root [Tree.SelectedNodes] list;\nselect events are sent to both selected nodes and the root node.\nSee [Tree.IsRootSelected] to check whether a select event on the root\nnode corresponds to the root node or another node.", Methods: []types.Method{{Name: "OpenAll", Doc: "OpenAll opens the node and all of its sub-nodes.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "CloseAll", Doc: "CloseAll closes the node and all of its sub-nodes.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "DeleteSelected", Doc: "DeleteSelected deletes selected items.\nMust be called from first node in selection.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Copy", Doc: "Copy copies the selected items to the clipboard.\nThis must be called on the first item in the selected list.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Cut", Doc: "Cut copies to [system.Clipboard] and deletes selected items.\nThis must be called on the first item in the selected list.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Paste", Doc: "Paste pastes clipboard at given node.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "InsertAfter", Doc: "InsertAfter inserts a new node in the tree\nafter this node, at the same (sibling) level,\nprompting for the type of node to insert.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "InsertBefore", Doc: "InsertBefore inserts a new node in the tree\nbefore this node, at the same (sibling) level,\nprompting for the type of node to insert\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "AddChildNode", Doc: "AddChildNode adds a new child node to this one in the tree,\nprompting the user for the type of node to add\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "DeleteNode", Doc: "DeleteNode deletes the tree node or sync node corresponding\nto this view node in the sync tree.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Duplicate", Doc: "Duplicate duplicates this node, and inserts the duplicate after this node\n(as a new sibling). If SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "EditNode", Doc: "EditNode pulls up a [Form] dialog for the node.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "inspectNode", Doc: "inspectNode pulls up a new Inspector window on the node.\nIf SyncNode is set, operates on Sync Tree.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "SyncNode", Doc: "SyncNode, if non-nil, is the [tree.Node] that this widget is\nviewing in the tree (the source). It should be set using\n[Tree.SyncTree]."}, {Name: "Text", Doc: "Text is the text to display for the tree item label, which automatically\ndefaults to the [tree.Node.Name] of the tree node. It has no effect\nif [Tree.SyncNode] is non-nil."}, {Name: "Icon", Doc: "Icon is an optional icon displayed to the the left of the text label."}, {Name: "IconOpen", Doc: "IconOpen is the icon to use for an open (expanded) branch;\nit defaults to [icons.KeyboardArrowDown]."}, {Name: "IconClosed", Doc: "IconClosed is the icon to use for a closed (collapsed) branch;\nit defaults to [icons.KeyboardArrowRight]."}, {Name: "IconLeaf", Doc: "IconLeaf is the icon to use for a terminal node branch that has no children;\nit defaults to [icons.Blank]."}, {Name: "TreeInit", Doc: "TreeInit is a function that can be set on the root node that is called\nwith each child tree node when it is initialized. It is only\ncalled with the root node itself in [Tree.SetTreeInit], so you\nshould typically call that instead of setting this directly."}, {Name: "Indent", Doc: "Indent is the amount to indent children relative to this node.\nIt should be set in a Styler like all other style properties."}, {Name: "OpenDepth", Doc: "OpenDepth is the depth for nodes be initialized as open (default 4).\nNodes beyond this depth will be initialized as closed."}, {Name: "Closed", Doc: "Closed is whether this tree node is currently toggled closed\n(children not visible)."}, {Name: "SelectMode", Doc: "SelectMode, when set on the root node, determines whether keyboard movements should update selection."}, {Name: "viewIndex", Doc: "linear index of this node within the entire tree.\nupdated on full rebuilds and may sometimes be off,\nbut close enough for expected uses"}, {Name: "widgetSize", Doc: "size of just this node widget.\nour alloc includes all of our children, but we only draw us."}, {Name: "Root", Doc: "Root is the cached root of the tree. It is automatically set."}, {Name: "SelectedNodes", Doc: "SelectedNodes holds the currently selected nodes.\nIt is only set on the root node. See [Tree.GetSelectedNodes]\nfor a version that also works on non-root nodes."}, {Name: "actStateLayer", Doc: "actStateLayer is the actual state layer of the tree, which\nshould be used when rendering it and its parts (but not its children).\nthe reason that it exists is so that the children of the tree\n(other trees) do not inherit its stateful background color, as\nthat does not look good."}, {Name: "inOpen", Doc: "inOpen is set in the Open method to prevent recursive opening for lazy-open nodes."}, {Name: "Branch", Doc: "Branch is the branch widget that is used to open and close the tree node."}}}) // NewTree returns a new [Tree] with the given optional parent: // Tree provides a graphical representation of a tree structure, diff --git a/core/valuer.go b/core/valuer.go index ef983fdc90..e1427baf36 100644 --- a/core/valuer.go +++ b/core/valuer.go @@ -124,13 +124,13 @@ func toValue(value any, tags reflect.StructTag) Value { return NewSwitch() case kind == reflect.Struct: num := reflectx.NumAllFields(uv) - if !noInline && (inline || num <= SystemSettings.StructInlineLength) { + if !noInline && (inline || num <= AppearanceSettings.InlineLengths.Struct) { return NewForm().SetInline(true) } return NewFormButton() case kind == reflect.Map: len := uv.Len() - if !noInline && (inline || len <= SystemSettings.MapInlineLength) { + if !noInline && (inline || len <= AppearanceSettings.InlineLengths.Map) { return NewKeyedList().SetInline(true) } return NewKeyedListButton() @@ -144,7 +144,7 @@ func toValue(value any, tags reflect.StructTag) Value { return NewTextField() } isStruct := (reflectx.NonPointerType(elemType).Kind() == reflect.Struct) - if !noInline && (inline || (!isStruct && sz <= SystemSettings.SliceInlineLength && !tree.IsNode(elemType))) { + if !noInline && (inline || (!isStruct && sz <= AppearanceSettings.InlineLengths.Slice && !tree.IsNode(elemType))) { return NewInlineList() } return NewListButton() diff --git a/go.mod b/go.mod index aa1cc164e5..335965af9b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module cogentcore.org/core go 1.23.4 require ( + codeberg.org/go-pdf/fpdf v0.11.1 github.com/Bios-Marcel/wastebasket/v2 v2.0.3 github.com/Masterminds/semver/v3 v3.2.1 github.com/Masterminds/vcs v1.13.3 diff --git a/go.sum b/go.sum index bb977f0c52..ecfd6fdc63 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs= +codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= github.com/Bios-Marcel/wastebasket/v2 v2.0.3 h1:TkoDPcSqluhLGE+EssHu7UGmLgUEkWg7kNyHyyJ3Q9g= github.com/Bios-Marcel/wastebasket/v2 v2.0.3/go.mod h1:769oPCv6eH7ugl90DYIsWwjZh4hgNmMS3Zuhe1bH6KU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= diff --git a/paint/ppath/pdf/font.go b/paint/ppath/pdf/font.go new file mode 100644 index 0000000000..bd351e3d69 --- /dev/null +++ b/paint/ppath/pdf/font.go @@ -0,0 +1,250 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package pdf + +/* +func (w *pdfWriter) writeFont(ref pdfRef, font *text.Font, vertical bool) { + // subset the font, we only write the used characters to the PDF CMap object to reduce its + // length. At the end of the function we add a CID to GID mapping to correctly select the + // right glyphID. + sfnt := font.SFNT + glyphIDs := w.fontSubset[font].List() // also when not subsetting, to minimize cmap table + if w.subset { + if sfnt.IsCFF && sfnt.CFF != nil { + sfnt.CFF.SetGlyphNames(nil) + } + sfntSubset, err := sfnt.Subset(glyphIDs, canvasFont.SubsetOptions{Tables: canvasFont.KeepPDFTables}) + if err == nil { + sfnt = sfntSubset + } else { + fmt.Println("WARNING: font subsetting failed:", err) + } + } + fontProgram := sfnt.Write() + + // calculate the character widths for the W array and shorten it + f := 1000.0 / float64(font.SFNT.Head.UnitsPerEm) + widths := make([]int, len(glyphIDs)+1) + for subsetGlyphID, glyphID := range glyphIDs { + widths[subsetGlyphID] = int(f*float64(font.SFNT.GlyphAdvance(glyphID)) + 0.5) + } + DW := widths[0] + W := pdfArray{} + i, j := 1, 1 + for k, width := range widths { + if k != 0 && width != widths[j] { + if 4 < k-j { // at about 5 equal widths, it would be shorter using the other notation format + if i < j { + arr := pdfArray{} + for _, w := range widths[i:j] { + arr = append(arr, w) + } + W = append(W, i, arr) + } + if widths[j] != DW { + W = append(W, j, k-1, widths[j]) + } + i = k + } + j = k + } + } + if i < len(widths) { + arr := pdfArray{} + for _, w := range widths[i:] { + arr = append(arr, w) + } + W = append(W, i, arr) + } + + // create ToUnicode CMap + var bfRange, bfChar strings.Builder + var bfRangeCount, bfCharCount int + startGlyphID := uint16(0) + startUnicode := uint32('\uFFFD') + length := uint16(1) + for subsetGlyphID, glyphID := range glyphIDs[1:] { + unicode := uint32(font.SFNT.Cmap.ToUnicode(glyphID)) + if 0x010000 <= unicode && unicode <= 0x10FFFF { + // UTF-16 surrogates + unicode -= 0x10000 + unicode = (0xD800+(unicode>>10)&0x3FF)<<16 + 0xDC00 + unicode&0x3FF + } + if uint16(subsetGlyphID+1) == startGlyphID+length && unicode == startUnicode+uint32(length) { + length++ + } else { + if 1 < length { + fmt.Fprintf(&bfRange, "\n<%04X> <%04X> <%04X>", startGlyphID, startGlyphID+length-1, startUnicode) + bfRangeCount++ + } else { + fmt.Fprintf(&bfChar, "\n<%04X> <%04X>", startGlyphID, startUnicode) + bfCharCount++ + } + startGlyphID = uint16(subsetGlyphID + 1) + startUnicode = unicode + length = 1 + } + } + if 1 < length { + fmt.Fprintf(&bfRange, "\n<%04X> <%04X> <%04X>", startGlyphID, startGlyphID+length-1, startUnicode) + bfRangeCount++ + } else { + fmt.Fprintf(&bfChar, "\n<%04X> <%04X>", startGlyphID, startUnicode) + bfCharCount++ + } + + toUnicode := bytes.Buffer{} + fmt.Fprintf(&toUnicode, `/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CIDSystemInfo <> def +/CMapName /Adobe-Identity-UCS def +/CMapType 2 def +1 begincodespacerange +<0000> endcodespacerange`) + if 0 < bfRangeCount { + fmt.Fprintf(&toUnicode, ` +%d beginbfrange%s endbfrange`, bfRangeCount, bfRange.String()) + } + if 0 < bfCharCount { + fmt.Fprintf(&toUnicode, ` +%d beginbfchar%s endbfchar`, bfCharCount, bfChar.String()) + } + fmt.Fprintf(&toUnicode, ` +endcmap +CMapName currentdict /CMap defineresource pop +end +end`) + toUnicodeStream := pdfStream{ + dict: pdfDict{}, + stream: toUnicode.Bytes(), + } + if w.compress { + toUnicodeStream.dict["Filter"] = pdfFilterFlate + } + toUnicodeRef := w.writeObject(toUnicodeStream) + + // write font program + var cidSubtype string + var fontfileKey pdfName + var fontfileRef pdfRef + if font.SFNT.IsTrueType { + cidSubtype = "CIDFontType2" + fontfileKey = "FontFile2" + fontfileRef = w.writeObject(pdfStream{ + dict: pdfDict{ + "Filter": pdfFilterFlate, + }, + stream: fontProgram, + }) + } else if font.SFNT.IsCFF { + cidSubtype = "CIDFontType0" + fontfileKey = "FontFile3" + fontfileRef = w.writeObject(pdfStream{ + dict: pdfDict{ + "Subtype": pdfName("OpenType"), + "Filter": pdfFilterFlate, + }, + stream: fontProgram, + }) + } + + // get name and CID subtype + name := font.Name() + if records := font.SFNT.Name.Get(canvasFont.NamePostScript); 0 < len(records) { + name = records[0].String() + } + baseFont := strings.ReplaceAll(name, " ", "") + if w.subset { + baseFont = "SUBSET+" + baseFont // TODO: give unique subset name + } + + encoding := "Identity-H" + if vertical { + encoding = "Identity-V" + } + + // in order to support more than 256 characters, we need to use a CIDFont dictionary which must be inside a Type0 font. Character codes in the stream are glyph IDs, however for subsetted fonts they are the _old_ glyph IDs, which is why we need the CIDToGIDMap + dict := pdfDict{ + "Type": pdfName("Font"), + "Subtype": pdfName("Type0"), + "BaseFont": pdfName(baseFont), + "Encoding": pdfName(encoding), // map character codes in the stream to CID with identity encoding, we additionally map CID to GID in the descendant font when subsetting, otherwise that is also identity + "ToUnicode": toUnicodeRef, + "DescendantFonts": pdfArray{pdfDict{ + "Type": pdfName("Font"), + "Subtype": pdfName(cidSubtype), + "BaseFont": pdfName(baseFont), + "DW": DW, + "W": W, + //"CIDToGIDMap": pdfName("Identity"), + "CIDSystemInfo": pdfDict{ + "Registry": "Adobe", + "Ordering": "Identity", + "Supplement": 0, + }, + "FontDescriptor": pdfDict{ + "Type": pdfName("FontDescriptor"), + "FontName": pdfName(baseFont), + "Flags": 4, // Symbolic + "FontBBox": pdfArray{ + int(f * float64(font.SFNT.Head.XMin)), + int(f * float64(font.SFNT.Head.YMin)), + int(f * float64(font.SFNT.Head.XMax)), + int(f * float64(font.SFNT.Head.YMax)), + }, + "ItalicAngle": float64(font.SFNT.Post.ItalicAngle), + "Ascent": int(f * float64(font.SFNT.Hhea.Ascender)), + "Descent": -int(f * float64(font.SFNT.Hhea.Descender)), + "CapHeight": int(f * float64(font.SFNT.OS2.SCapHeight)), + "StemV": 80, // taken from Inkscape, should be calculated somehow, maybe use: 10+220*(usWeightClass-50)/900 + fontfileKey: fontfileRef, + }, + }}, + } + + if !w.subset { + cidToGIDMap := make([]byte, 2*len(glyphIDs)) + for subsetGlyphID, glyphID := range glyphIDs { + j := int(subsetGlyphID) * 2 + cidToGIDMap[j+0] = byte((glyphID & 0xFF00) >> 8) + cidToGIDMap[j+1] = byte(glyphID & 0x00FF) + } + cidToGIDMapStream := pdfStream{ + dict: pdfDict{}, + stream: cidToGIDMap, + } + if w.compress { + cidToGIDMapStream.dict["Filter"] = pdfFilterFlate + } + cidToGIDMapRef := w.writeObject(cidToGIDMapStream) + dict["DescendantFonts"].(pdfArray)[0].(pdfDict)["CIDToGIDMap"] = cidToGIDMapRef + } + + w.objOffsets[ref-1] = w.pos + w.write("%v 0 obj\n", ref) + w.writeVal(dict) + w.write("\nendobj\n") +} + +func (w *pdfWriter) writeFonts(fontMap map[*text.Font]pdfRef, vertical bool) { + // sort fonts by ref to make PDF deterministic + refs := make([]pdfRef, 0, len(fontMap)) + refMap := make(map[pdfRef]*text.Font, len(fontMap)) + for font, ref := range fontMap { + refs = append(refs, ref) + refMap[ref] = font + } + sort.Slice(refs, func(i, j int) bool { + return refs[i] < refs[j] + }) + for _, ref := range refs { + w.writeFont(ref, refMap[ref], vertical) + } +} +*/ diff --git a/paint/ppath/pdf/page.go b/paint/ppath/pdf/page.go new file mode 100644 index 0000000000..dd095d6249 --- /dev/null +++ b/paint/ppath/pdf/page.go @@ -0,0 +1,618 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package pdf + +import ( + "bytes" + "fmt" + "image" + "log/slog" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" + "golang.org/x/text/encoding/charmap" +) + +type pdfPageWriter struct { + *bytes.Buffer + pdf *pdfWriter + width, height float32 + resources pdfDict + annots pdfArray + + graphicsStates map[float32]pdfName + style styles.Paint + inTextObject bool + textPosition math32.Matrix2 + textCharSpace float32 + textRenderMode int +} + +func (w *pdfPageWriter) writePage(parent pdfRef) pdfRef { + b := w.Bytes() + if 0 < len(b) && b[0] == ' ' { + b = b[1:] + } + stream := pdfStream{ + dict: pdfDict{}, + stream: b, + } + if w.pdf.compress { + stream.dict["Filter"] = pdfFilterFlate + } + contents := w.pdf.writeObject(stream) + page := pdfDict{ + "Type": pdfName("Page"), + "Parent": parent, + "MediaBox": pdfArray{0.0, 0.0, w.width * ptPerMm, w.height * ptPerMm}, + "Resources": w.resources, + "Group": pdfDict{ + "Type": pdfName("Group"), + "S": pdfName("Transparency"), + "I": true, + "CS": pdfName("DeviceRGB"), + }, + "Contents": contents, + } + if 0 < len(w.annots) { + page["Annots"] = w.annots + } + return w.pdf.writeObject(page) +} + +// AddAnnotation adds an annotation. +func (w *pdfPageWriter) AddURIAction(uri string, rect math32.Box2) { + annot := pdfDict{ + "Type": pdfName("Annot"), + "Subtype": pdfName("Link"), + "Border": pdfArray{0, 0, 0}, + "Rect": pdfArray{rect.Min.X * ptPerMm, rect.Min.Y * ptPerMm, rect.Max.X * ptPerMm, rect.Max.Y * ptPerMm}, + "Contents": uri, + "A": pdfDict{ + "S": pdfName("URI"), + "URI": uri, + }, + } + w.annots = append(w.annots, annot) +} + +// SetFill sets the fill style values where different from current. +func (w *pdfPageWriter) SetFill(fill *styles.Fill) { + if w.style.Fill.Color != fill.Color || w.style.Fill.Opacity != fill.Opacity { + w.SetFillColor(fill) + } + w.style.Fill = *fill +} + +// SetAlpha sets the transparency value. +func (w *pdfPageWriter) SetAlpha(alpha float32) { + gs := w.getOpacityGS(alpha) + fmt.Fprintf(w, " /%v gs", gs) +} + +// SetFillColor sets the filling color (image). +func (w *pdfPageWriter) SetFillColor(fill *styles.Fill) { + switch x := fill.Color.(type) { + // todo: pattern, image + case *gradient.Linear: + case *gradient.Radial: + // TODO: should we unset cs? + // fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(fill.Gradient)) + case *image.Uniform: + clr := colors.ApplyOpacity(colors.AsRGBA(x), fill.Opacity) + a := float32(clr.A) / 255.0 + if clr.R == clr.G && clr.R == clr.B { + fmt.Fprintf(w, " %v g", dec(float32(clr.R)/255.0/a)) + } else { + fmt.Fprintf(w, " %v %v %v rg", dec(float32(clr.R)/255.0/a), dec(float32(clr.G)/255.0/a), dec(float32(clr.B)/255.0/a)) + } + w.SetAlpha(a) + } +} + +// SetStroke sets the stroke style values where different from current. +func (w *pdfPageWriter) SetStroke(stroke *styles.Stroke) { + if w.style.Stroke.Color != stroke.Color || w.style.Stroke.Opacity != stroke.Opacity { + w.SetStrokeColor(stroke) + } + if w.style.Stroke.Width.Dots != stroke.Width.Dots { + w.SetStrokeWidth(stroke.Width.Dots) + } + if w.style.Stroke.Cap != stroke.Cap { + w.SetStrokeCap(stroke.Cap) + } + if w.style.Stroke.Join != stroke.Join || (w.style.Stroke.Join == ppath.JoinMiter && w.style.Stroke.MiterLimit != stroke.MiterLimit) { + w.SetStrokeJoin(stroke.Join, stroke.MiterLimit) + } + if len(stroke.Dashes) > 0 { // always do + w.SetDashes(stroke.DashOffset, stroke.Dashes) + } else { + if len(w.style.Stroke.Dashes) > 0 { + w.SetDashes(0, nil) + } + } + w.style.Stroke = *stroke +} + +// SetStrokeColor sets the stroking color (image). +func (w *pdfPageWriter) SetStrokeColor(stroke *styles.Stroke) { + switch x := stroke.Color.(type) { + case *gradient.Linear: + case *gradient.Radial: + // TODO: should we unset cs? + // fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(stroke.Gradient)) + case *image.Uniform: + clr := colors.ApplyOpacity(colors.AsRGBA(x), stroke.Opacity) + a := float32(clr.A) / 255.0 + if clr.R == clr.G && clr.R == clr.B { + fmt.Fprintf(w, " %v G", dec(float32(clr.R)/255.0/a)) + } else { + fmt.Fprintf(w, " %v %v %v RG", dec(float32(clr.R)/255.0/a), dec(float32(clr.G)/255.0/a), dec(float32(clr.B)/255.0/a)) + } + w.SetAlpha(a) + } +} + +// SetStrokeWidth sets the stroke width. +func (w *pdfPageWriter) SetStrokeWidth(lineWidth float32) { + fmt.Fprintf(w, " %v w", dec(lineWidth)) +} + +// SetStrokeCap sets the stroke cap type. +func (w *pdfPageWriter) SetStrokeCap(capper ppath.Caps) { + var lineCap int + switch capper { + case ppath.CapButt: + lineCap = 0 + case ppath.CapRound: + lineCap = 1 + case ppath.CapSquare: + lineCap = 2 + default: + slog.Error("pdfWriter", "StrokeCap not supported", capper) + } + fmt.Fprintf(w, " %d J", lineCap) +} + +// SetStrokeJoin sets the stroke join type. +func (w *pdfPageWriter) SetStrokeJoin(joiner ppath.Joins, miterLimit float32) { + var lineJoin int + switch joiner { + case ppath.JoinBevel: + lineJoin = 2 + case ppath.JoinRound: + lineJoin = 1 + case ppath.JoinMiter: + lineJoin = 0 + default: + slog.Error("pdfWriter", "StrokeJoin not supported", joiner) + } + fmt.Fprintf(w, " %d j", lineJoin) + if lineJoin == 0 { + fmt.Fprintf(w, " %v M", dec(miterLimit)) + } +} + +// SetDashes sets the dash phase and array. +func (w *pdfPageWriter) SetDashes(dashPhase float32, dashArray []float32) { + if len(dashArray)%2 == 1 { + dashArray = append(dashArray, dashArray...) + } + + // PDF can't handle negative dash phases + if dashPhase < 0.0 { + totalLength := float32(0.0) + for _, dash := range dashArray { + totalLength += dash + } + for dashPhase < 0.0 { + dashPhase += totalLength + } + } + + dashes := append(dashArray, dashPhase) + if len(dashes) == 1 { + fmt.Fprintf(w, " [] 0 d") + dashes[0] = 0.0 + } else { + fmt.Fprintf(w, " [%v", dec(dashes[0])) + for _, dash := range dashes[1 : len(dashes)-1] { + fmt.Fprintf(w, " %v", dec(dash)) + } + fmt.Fprintf(w, "] %v d", dec(dashes[len(dashes)-1])) + } +} + +// SetFont sets the font. +func (w *pdfPageWriter) SetFont(sty *rich.Style, tsty *text.Style) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + size := tsty.FontHeight(sty) + ref := w.pdf.getFont(sty, tsty) + if _, ok := w.resources["Font"]; !ok { + w.resources["Font"] = pdfDict{} + } else { + for name, fontRef := range w.resources["Font"].(pdfDict) { + if ref == fontRef { + fmt.Fprintf(w, " /%v %v Tf", name, dec(size)) + return nil + } + } + } + + name := pdfName(fmt.Sprintf("F%d", len(w.resources["Font"].(pdfDict)))) + w.resources["Font"].(pdfDict)[name] = ref + fmt.Fprintf(w, " /%v %v Tf", name, dec(size)) + return nil +} + +// SetTextPosition sets the text position. +func (w *pdfPageWriter) SetTextPosition(m math32.Matrix2) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + if ppath.Equal(m.XX, w.textPosition.XX) && ppath.Equal(m.XY, w.textPosition.XY) && ppath.Equal(m.YX, w.textPosition.YX) && ppath.Equal(m.YY, w.textPosition.YY) { + d := w.textPosition.Inverse().MulVector2AsPoint(math32.Vec2(m.X0, m.Y0)) + fmt.Fprintf(w, " %v %v Td", dec(d.X), dec(d.Y)) + } else { + fmt.Fprintf(w, " %s Tm", mat2(m)) + } + w.textPosition = m + return nil +} + +// SetTextRenderMode sets the text rendering mode. +func (w *pdfPageWriter) SetTextRenderMode(mode int) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + fmt.Fprintf(w, " %d Tr", mode) + w.textRenderMode = mode + return nil +} + +// SetTextCharSpace sets the text character spacing. +func (w *pdfPageWriter) SetTextCharSpace(space float32) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + fmt.Fprintf(w, " %v Tc", dec(space)) + w.textCharSpace = space + return nil +} + +// StartTextObject starts a text object. +func (w *pdfPageWriter) StartTextObject() error { + if w.inTextObject { + return errors.Log(errors.New("pdfWriter: already in text object")) + } + fmt.Fprintf(w, " BT") + w.textPosition = math32.Identity2() + w.inTextObject = true + return nil +} + +// EndTextObject ends a text object. +func (w *pdfPageWriter) EndTextObject() error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + fmt.Fprintf(w, " ET") + w.inTextObject = false + return nil +} + +// WriteText writes text using current text style. +func (w *pdfPageWriter) WriteText(tx string) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + if len(tx) == 0 { + return nil + } + + first := true + write := func(s string) { + if first { + fmt.Fprintf(w, "(") + first = false + } else { + fmt.Fprintf(w, " (") + } + rs := []rune(s) + for _, r := range rs { + c, ok := charmap.Windows1252.EncodeRune(r) + if !ok { + if '\u2000' <= r && r <= '\u200A' { + c = ' ' + } + } + switch c { + case '\n': + w.WriteByte('\\') + w.WriteByte('n') + case '\r': + w.WriteByte('\\') + w.WriteByte('r') + case '\t': + w.WriteByte('\\') + w.WriteByte('t') + case '\b': + w.WriteByte('\\') + w.WriteByte('b') + case '\f': + w.WriteByte('\\') + w.WriteByte('f') + case '\\', '(', ')': + w.WriteByte('\\') + w.WriteByte(c) + default: + w.WriteByte(c) + } + } + fmt.Fprintf(w, ")") + } + + // position := w.textPosition + // if glyphs, ok := TJ[0].([]canvasText.Glyph); ok && 0 < len(glyphs) && mode != ppath.HorizontalTB && !glyphs[0].Vertical { + // glyphRotation, glyphOffset := glyphs[0].Rotation(), glyphs[0].YOffset-int32(glyphs[0].SFNT.Head.UnitsPerEm/2) + // if glyphRotation != canvasText.NoRotation || glyphOffset != 0 { + // w.SetTextPosition(position.Rotate(float32(glyphRotation)).Translate(0.0, glyphs[0].Size/float32(glyphs[0].SFNT.Head.UnitsPerEm)*mmPerPt*float32(glyphOffset))) + // } + // } + + // f := 1000.0 / float32(w.font.SFNT.Head.UnitsPerEm) + fmt.Fprintf(w, "[") + write(tx) + + // for _, tj := range TJ { + // switch val := tj.(type) { + // case []canvasText.Glyph: + // i := 0 + // for j, glyph := range val { + // if mode == ppath.HorizontalTB || !glyph.Vertical { + // origXAdvance := int32(w.font.SFNT.GlyphAdvance(glyph.ID)) + // if glyph.XAdvance != origXAdvance { + // write(val[i : j+1]) + // fmt.Fprintf(w, " %d", -int(f*float32(glyph.XAdvance-origXAdvance)+0.5)) + // i = j + 1 + // } + // } else { + // origYAdvance := -int32(w.font.SFNT.GlyphVerticalAdvance(glyph.ID)) + // if glyph.YAdvance != origYAdvance { + // write(val[i : j+1]) + // fmt.Fprintf(w, " %d", -int(f*float32(glyph.YAdvance-origYAdvance)+0.5)) + // i = j + 1 + // } + // } + // } + // write(val[i:]) + // case string: + // i := 0 + // if mode == ppath.HorizontalTB { + // var rPrev rune + // for j, r := range val { + // if i < j { + // kern := w.font.SFNT.Kerning(w.font.SFNT.GlyphIndex(rPrev), w.font.SFNT.GlyphIndex(r)) + // if kern != 0 { + // writeString(val[i:j]) + // fmt.Fprintf(w, " %d", -int(f*float32(kern)+0.5)) + // i = j + // } + // } + // rPrev = r + // } + // } + // writeString(val[i:]) + // case float32: + // fmt.Fprintf(w, " %d", -int(val*1000.0/w.fontSize+0.5)) + // case int: + // fmt.Fprintf(w, " %d", -int(float32(val)*1000.0/w.fontSize+0.5)) + // } + // } + fmt.Fprintf(w, "]TJ") + return nil +} + +// DrawImage embeds and draws an image, as a lossless (PNG) +func (w *pdfPageWriter) DrawImage(img image.Image, m math32.Matrix2) { + size := img.Bounds().Size() + + // add clipping path around image for smooth edges when rotating + outerRect := math32.B2(0.0, 0.0, float32(size.X), float32(size.Y)).MulMatrix2(m) + bl := m.MulVector2AsPoint(math32.Vector2{0, 0}) + br := m.MulVector2AsPoint(math32.Vector2{float32(size.X), 0}) + tl := m.MulVector2AsPoint(math32.Vector2{0, float32(size.Y)}) + tr := m.MulVector2AsPoint(math32.Vector2{float32(size.X), float32(size.Y)}) + fmt.Fprintf(w, " q %v %v %v %v re W n", dec(outerRect.Min.X), dec(outerRect.Min.Y), dec(outerRect.Size().X), dec(outerRect.Size().Y)) + fmt.Fprintf(w, " %v %v m %v %v l %v %v l %v %v l h W n", dec(bl.X), dec(bl.Y), dec(tl.X), dec(tl.Y), dec(tr.X), dec(tr.Y), dec(br.X), dec(br.Y)) + + ref := w.embedImage(img) + if _, ok := w.resources["XObject"]; !ok { + w.resources["XObject"] = pdfDict{} + } + name := pdfName(fmt.Sprintf("Im%d", len(w.resources["XObject"].(pdfDict)))) + w.resources["XObject"].(pdfDict)[name] = ref + + m = m.Scale(float32(size.X), float32(size.Y)) + w.SetAlpha(1.0) + fmt.Fprintf(w, " %s cm /%v Do Q", mat2(m), name) +} + +// embedImage does a lossless image embedding. +func (w *pdfPageWriter) embedImage(img image.Image) pdfRef { + if ref, ok := w.pdf.images[img]; ok { + return ref + } + + var hasMask bool + size := img.Bounds().Size() + filter := pdfFilterFlate + sp := img.Bounds().Min // starting point + stream := make([]byte, size.X*size.Y*3) + streamMask := make([]byte, size.X*size.Y) + for y := 0; y < size.Y; y++ { + for x := 0; x < size.X; x++ { + i := (y*size.X + x) * 3 + R, G, B, A := img.At(sp.X+x, sp.Y+y).RGBA() + if A != 0 { + stream[i+0] = byte((R * 65535 / A) >> 8) + stream[i+1] = byte((G * 65535 / A) >> 8) + stream[i+2] = byte((B * 65535 / A) >> 8) + streamMask[y*size.X+x] = byte(A >> 8) + } + if A>>8 != 255 { + hasMask = true + } + } + } + + dict := pdfDict{ + "Type": pdfName("XObject"), + "Subtype": pdfName("Image"), + "Width": size.X, + "Height": size.Y, + "ColorSpace": pdfName("DeviceRGB"), + "BitsPerComponent": 8, + "Interpolate": true, + "Filter": filter, + } + + if hasMask { + dict["SMask"] = w.pdf.writeObject(pdfStream{ + dict: pdfDict{ + "Type": pdfName("XObject"), + "Subtype": pdfName("Image"), + "Width": size.X, + "Height": size.Y, + "ColorSpace": pdfName("DeviceGray"), + "BitsPerComponent": 8, + "Interpolate": true, + "Filter": pdfFilterFlate, + }, + stream: streamMask, + }) + } + + ref := w.pdf.writeObject(pdfStream{ + dict: dict, + stream: stream, + }) + w.pdf.images[img] = ref + return ref +} + +func (w *pdfPageWriter) getOpacityGS(a float32) pdfName { + if name, ok := w.graphicsStates[a]; ok { + return name + } + name := pdfName(fmt.Sprintf("A%d", len(w.graphicsStates))) + w.graphicsStates[a] = name + + if _, ok := w.resources["ExtGState"]; !ok { + w.resources["ExtGState"] = pdfDict{} + } + w.resources["ExtGState"].(pdfDict)[name] = pdfDict{ + "CA": a, + "ca": a, + } + return name +} + +/* +func (w *pdfPageWriter) getPattern(gradient ppath.Gradient) pdfName { + // TODO: support patterns/gradients with alpha channel + shading := pdfDict{ + "ColorSpace": pdfName("DeviceRGB"), + } + if g, ok := gradient.(*ppath.LinearGradient); ok { + shading["ShadingType"] = 2 + shading["Coords"] = pdfArray{g.Start.X * ptPerMm, g.Start.Y * ptPerMm, g.End.X * ptPerMm, g.End.Y * ptPerMm} + shading["Function"] = patternStopsFunction(g.Stops) + shading["Extend"] = pdfArray{true, true} + } else if g, ok := gradient.(*ppath.RadialGradient); ok { + shading["ShadingType"] = 3 + shading["Coords"] = pdfArray{g.C0.X * ptPerMm, g.C0.Y * ptPerMm, g.R0 * ptPerMm, g.C1.X * ptPerMm, g.C1.Y * ptPerMm, g.R1 * ptPerMm} + shading["Function"] = patternStopsFunction(g.Stops) + shading["Extend"] = pdfArray{true, true} + } + pattern := pdfDict{ + "Type": pdfName("Pattern"), + "PatternType": 2, + "Shading": shading, + } + + if _, ok := w.resources["Pattern"]; !ok { + w.resources["Pattern"] = pdfDict{} + } + for name, pat := range w.resources["Pattern"].(pdfDict) { + if reflect.DeepEqual(pat, pattern) { + return name + } + } + name := pdfName(fmt.Sprintf("P%d", len(w.resources["Pattern"].(pdfDict)))) + w.resources["Pattern"].(pdfDict)[name] = pattern + return name +} + +func patternStopsFunction(stops ppath.Stops) pdfDict { + if len(stops) < 2 { + return pdfDict{} + } + + fs := []pdfDict{} + encode := pdfArray{} + bounds := pdfArray{} + if !ppath.Equal(stops[0].Offset, 0.0) { + fs = append(fs, patternStopFunction(stops[0], stops[0])) + encode = append(encode, 0, 1) + bounds = append(bounds, stops[0].Offset) + } + for i := 0; i < len(stops)-1; i++ { + fs = append(fs, patternStopFunction(stops[i], stops[i+1])) + encode = append(encode, 0, 1) + if i != 0 { + bounds = append(bounds, stops[1].Offset) + } + } + if !ppath.Equal(stops[len(stops)-1].Offset, 1.0) { + fs = append(fs, patternStopFunction(stops[len(stops)-1], stops[len(stops)-1])) + encode = append(encode, 0, 1) + } + if len(fs) == 1 { + return fs[0] + } + return pdfDict{ + "FunctionType": 3, + "Domain": pdfArray{0, 1}, + "Encode": encode, + "Bounds": bounds, + "Functions": fs, + } +} + +func patternStopFunction(s0, s1 ppath.Stop) pdfDict { + a0 := float32(s0.Color.A) / 255.0 + a1 := float32(s1.Color.A) / 255.0 + return pdfDict{ + "FunctionType": 2, + "Domain": pdfArray{0, 1}, + "N": 1, + "C0": pdfArray{float32(s0.Color.R) / 255.0 / a0, float32(s0.Color.G) / 255.0 / a0, float32(s0.Color.B) / 255.0 / a0}, + "C1": pdfArray{float32(s1.Color.R) / 255.0 / a1, float32(s1.Color.G) / 255.0 / a1, float32(s1.Color.B) / 255.0 / a1}, + } +} + +*/ diff --git a/paint/ppath/pdf/pdf.go b/paint/ppath/pdf/pdf.go new file mode 100644 index 0000000000..9c055c4b60 --- /dev/null +++ b/paint/ppath/pdf/pdf.go @@ -0,0 +1,263 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package pdf + +import ( + "image" + "io" + + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles" + "cogentcore.org/core/text/shaped" +) + +// type Options struct { +// Compress bool +// SubsetFonts bool +// canvas.ImageEncoding +// } +// +// var DefaultOptions = Options{ +// Compress: true, +// SubsetFonts: true, +// ImageEncoding: canvas.Lossless, +// } + +// PDF is a portable document format renderer. +type PDF struct { + w *pdfPageWriter + width, height float32 + // opts *Options +} + +// New returns a portable document format (PDF) renderer. +func New(w io.Writer, width, height float32) *PDF { + // if opts == nil { + // defaultOptions := DefaultOptions + // opts = &defaultOptions + // } + + page := newPDFWriter(w).NewPage(width, height) + // page.pdf.SetCompression(opts.Compress) + // page.pdf.SetFontSubsetting(opts.SubsetFonts) + return &PDF{ + w: page, + width: width, + height: height, + // opts: opts, + } +} + +// SetImageEncoding sets the image encoding to Loss or Lossless. +// func (r *PDF) SetImageEncoding(enc canvas.ImageEncoding) { +// r.opts.ImageEncoding = enc +// } + +// SetInfo sets the document's title, subject, keywords, author and creator. +func (r *PDF) SetInfo(title, subject, keywords, author, creator string) { + r.w.pdf.SetTitle(title) + r.w.pdf.SetSubject(subject) + r.w.pdf.SetKeywords(keywords) + r.w.pdf.SetAuthor(author) + r.w.pdf.SetCreator(creator) +} + +// SetLang sets the document's language. It must adhere the RFC 3066 specification on Language-Tag, eg. es-CL. +func (r *PDF) SetLang(lang string) { + r.w.pdf.SetLang(lang) +} + +// NewPage starts adds a new page where further rendering will be written to. +func (r *PDF) NewPage(width, height float32) { + r.w = r.w.pdf.NewPage(width, height) +} + +// AddLink adds a link to the PDF document. +func (r *PDF) AddLink(uri string, rect math32.Box2) { + r.w.AddURIAction(uri, rect) +} + +// Close finished and closes the PDF. +func (r *PDF) Close() error { + return r.w.pdf.Close() +} + +// Size returns the size of the canvas in millimeters. +func (r *PDF) Size() (float32, float32) { + return r.width, r.height +} + +// RenderPath renders a path to the canvas using a style and a transformation matrix. +func (r *PDF) RenderPath(path ppath.Path, style *styles.Paint, m math32.Matrix2) { + // PDFs don't support the arcs joiner, miter joiner (not clipped), + // or miter joiner (clipped) with non-bevel fallback + strokeUnsupported := false + if style.Stroke.Join == ppath.JoinArcs { + strokeUnsupported = true + } else if style.Stroke.Join == ppath.JoinMiter { + if style.Stroke.MiterLimit == 0 { + strokeUnsupported = true + } + // } else if _, ok := miter.GapJoiner.(canvas.BevelJoiner); !ok { + // strokeUnsupported = true + // } + } + scale := math32.Sqrt(math32.Abs(m.Det())) + stk := style.Stroke + stk.Width.Dots *= scale + stk.DashOffset, stk.Dashes = ppath.ScaleDash(stk.Width.Dots, stk.DashOffset, stk.Dashes) + + // PDFs don't support connecting first and last dashes if path is closed, + // so we move the start of the path if this is the case + // TODO: closing dashes + //if style.DashesClose { + // strokeUnsupported = true + //} + + closed := false + data := path.Clone().Transform(m).ToPDF() + if 1 < len(data) && data[len(data)-1] == 'h' { + data = data[:len(data)-2] + closed = true + } + + if style.HasStroke() && strokeUnsupported { // todo + /* // style.HasStroke() && strokeUnsupported + if style.HasFill() { + r.w.SetFill(style.Fill) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) + if style.Fill.Rule == canvas.EvenOdd { + r.w.Write([]byte("*")) + } + } + + // stroke settings unsupported by PDF, draw stroke explicitly + if style.IsDashed() { + path = path.Dash(style.DashOffset, style.Dashes...) + } + path = path.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) + + r.w.SetFill(style.Stroke) + r.w.Write([]byte(" ")) + r.w.Write([]byte(path.Transform(m).ToPDF())) + r.w.Write([]byte(" f")) + */ + return + } + if style.HasFill() && !style.HasStroke() { + r.w.SetFill(&style.Fill) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } else if !style.HasFill() && style.HasStroke() { + r.w.SetStroke(&stk) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" s")) + } else { + r.w.Write([]byte(" S")) + } + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } else if style.HasFill() && style.HasStroke() { + // sameAlpha := style.Fill.IsColor() && style.Stroke.IsColor() && style.Fill.Color.A == style.Stroke.Color.A + // todo: + sameAlpha := true + if sameAlpha { + r.w.SetFill(&style.Fill) + r.w.SetStroke(&style.Stroke) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" b")) + } else { + r.w.Write([]byte(" B")) + } + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } + /* + else { + r.w.SetFill(style.Fill) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + + r.w.SetStroke(style.Stroke) + r.w.SetLineWidth(style.StrokeWidth) + r.w.SetLineCap(style.StrokeCapper) + r.w.SetLineJoin(style.StrokeJoiner) + r.w.SetDashes(style.DashOffset, style.Dashes) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" s")) + } else { + r.w.Write([]byte(" S")) + } + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } + */ + } +} + +// RenderText renders a text object to the canvas using a transformation matrix, +// (the translation component specifies the starting offset) +func (r *PDF) RenderText(text *shaped.Lines, m math32.Matrix2) { + // text.WalkDecorations(func(fill canvas.Paint, p *canvas.Path) { + // style := canvas.DefaultStyle + // style.Fill = fill + // r.RenderPath(p, style, m) + // }) + + // todo: copy from other render cases + // text.WalkSpans(func(x, y float32, span canvas.TextSpan) { + // if span.IsText() { + // style := canvas.DefaultStyle + // style.Fill = span.Face.Fill + // + // r.w.StartTextObject() + // r.w.SetFill(span.Face.Fill) + // r.w.SetFont(span.Face.Font, span.Face.Size, span.Direction) + // r.w.SetTextPosition(m.Translate(x, y).Shear(span.Face.FauxItalic, 0.0)) + // + // if 0.0 < span.Face.FauxBold { + // r.w.SetTextRenderMode(2) + // r.w.SetStroke(span.Face.Fill) + // fmt.Fprintf(r.w, " %v w", dec(span.Face.FauxBold*2.0)) + // } else { + // r.w.SetTextRenderMode(0) + // } + // r.w.WriteText(text.WritingMode, span.Glyphs) + // r.w.EndTextObject() + // } else { + // for _, obj := range span.Objects { + // obj.Canvas.RenderViewTo(r, m.Mul(obj.View(x, y, span.Face))) + // } + // } + // }) +} + +// RenderImage renders an image to the canvas using a transformation matrix. +func (r *PDF) RenderImage(img image.Image, m math32.Matrix2) { + r.w.DrawImage(img, m) +} diff --git a/paint/ppath/pdf/pdf_test.go b/paint/ppath/pdf/pdf_test.go new file mode 100644 index 0000000000..35cda406d2 --- /dev/null +++ b/paint/ppath/pdf/pdf_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package pdf + +import ( + "bytes" + "fmt" + "os" + "testing" + + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles" +) + +func TestPDFPath(t *testing.T) { + p := ppath.MustParseSVGPath("L20 0") + var b bytes.Buffer + pd := New(&b, 50, 50) + + sty := styles.NewPaint() + sty.Defaults() + sty.Stroke.Color = colors.Uniform(colors.Blue) + sty.Fill.Color = colors.Uniform(colors.Red) + sty.Stroke.Width.Px(2) + sty.ToDots() + + pd.RenderPath(p, sty, math32.Translate2D(20, 20)) + pd.Close() + + fmt.Println(b.String()) + os.Mkdir("testdata", 0777) + os.WriteFile("testdata/path.pdf", b.Bytes(), 0666) + + // pdfCompress = false + // buf := &bytes.Buffer{} + // c.WritePDF(buf) + // test.T(t, buf.String(), `%PDF-1.7 + //1 0 obj + //<< /Length 14 >> stream + //0 0 m 10 0 l f + //endstream + //endobj + //2 0 obj + //<< /Type /Page /Contents 1 0 R /Group << /Type /Group /CS /DeviceRGB /I true /S /Transparency >> /MediaBox [0 0 10 10] /Parent 2 0 R /Resources << >> >> + //endobj + //3 0 obj + //<< /Type /Pages /Count 1 /Kids [2 0 R] >> + //endobj + //4 0 obj + //<< /Type /Catalog /Pages 3 0 R >> + //endobj + //xref + //0 5 + //0000000000 65535 f + //0000000009 00000 n + //0000000073 00000 n + //0000000241 00000 n + //0000000298 00000 n + //trailer + //<< /Root 4 0 R /Size 4 >> + //starxref + //347 + //%%EOF`) +} + +// func TestPDFPath(t *testing.T) { +// buf := &bytes.Buffer{} +// pdf := newPDFWriter(buf).NewPage(210.0, 297.0) +// pdf.SetAlpha(0.5) +// pdf.SetFill(canvas.Paint{Color: canvas.Red}) +// pdf.SetStroke(canvas.Paint{Color: canvas.Blue}) +// pdf.SetLineWidth(5.0) +// pdf.SetLineCap(canvas.RoundCap) +// pdf.SetLineJoin(canvas.RoundJoin) +// pdf.SetDashes(2.0, []float64{1.0, 2.0, 3.0}) +// test.String(t, pdf.String(), " 2.8346457 0 0 2.8346457 0 0 cm /A0 gs 1 0 0 rg /A1 gs 0 0 1 RG 5 w 1 J 1 j [1 2 3 1 2 3] 2 d") +// } diff --git a/paint/ppath/pdf/writer.go b/paint/ppath/pdf/writer.go new file mode 100644 index 0000000000..9d3cc802e0 --- /dev/null +++ b/paint/ppath/pdf/writer.go @@ -0,0 +1,491 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package pdf + +import ( + "bytes" + "compress/zlib" + "encoding/ascii85" + "fmt" + "image" + "io" + "math" + "sort" + "strings" + "time" + "unicode/utf16" + + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" +) + +// TODO: Invalid graphics transparency, Group has a transparency S entry or the S entry is null +// TODO: Invalid Color space, The operator "g" can't be used without Color Profile + +type pdfWriter struct { + w io.Writer + err error + + pos int + objOffsets []int + pages []pdfRef + + page *pdfPageWriter + fontsStd map[string]pdfRef + // todo: for custom fonts: + // fontSubset map[*text.Font]*ppath.FontSubsetter + // fontsH map[*text.Font]pdfRef + // fontsV map[*text.Font]pdfRef + images map[image.Image]pdfRef + compress bool + subset bool + title string + subject string + keywords string + author string + creator string + lang string +} + +func newPDFWriter(writer io.Writer) *pdfWriter { + w := &pdfWriter{ + w: writer, + objOffsets: []int{0, 0, 0}, // catalog, metadata, page tree + fontsStd: map[string]pdfRef{}, + // fontSubset: map[*text.Font]*ppath.FontSubsetter{}, + // fontsH: map[*text.Font]pdfRef{}, + // fontsV: map[*text.Font]pdfRef{}, + images: map[image.Image]pdfRef{}, + compress: false, + subset: true, + } + + w.write("%%PDF-1.7\n%%Ŧǟċơ\n") + return w +} + +// SetCompression enable the compression of the streams. +func (w *pdfWriter) SetCompression(compress bool) { + w.compress = compress +} + +// SeFontSubsetting enables the subsetting of embedded fonts. +func (w *pdfWriter) SetFontSubsetting(subset bool) { + w.subset = subset +} + +// SetTitle sets the document's title. +func (w *pdfWriter) SetTitle(title string) { + w.title = title +} + +// SetSubject sets the document's subject. +func (w *pdfWriter) SetSubject(subject string) { + w.subject = subject +} + +// SetKeywords sets the document's keywords. +func (w *pdfWriter) SetKeywords(keywords string) { + w.keywords = keywords +} + +// SetAuthor sets the document's author. +func (w *pdfWriter) SetAuthor(author string) { + w.author = author +} + +// SetCreator sets the document's creator. +func (w *pdfWriter) SetCreator(creator string) { + w.creator = creator +} + +// SetLang sets the document's language. +func (w *pdfWriter) SetLang(lang string) { + w.lang = lang +} + +func (w *pdfWriter) writeBytes(b []byte) { + if w.err != nil { + return + } + n, err := w.w.Write(b) + w.pos += n + w.err = err +} + +func (w *pdfWriter) write(s string, v ...interface{}) { + if w.err != nil { + return + } + n, err := fmt.Fprintf(w.w, s, v...) + w.pos += n + w.err = err +} + +type pdfRef int +type pdfName string +type pdfArray []interface{} +type pdfDict map[pdfName]interface{} +type pdfFilter string +type pdfStream struct { + dict pdfDict + stream []byte +} + +const ( + pdfFilterASCII85 pdfFilter = "ASCII85Decode" + pdfFilterFlate pdfFilter = "FlateDecode" + pdfFilterDCT pdfFilter = "DCTDecode" +) + +func pdfValContinuesName(val any) bool { + switch val.(type) { + case string, pdfName, pdfFilter, pdfArray, pdfDict, pdfStream: + return false + } + return true +} + +func (w *pdfWriter) writeVal(i interface{}) { + switch v := i.(type) { + case bool: + if v { + w.write("true") + } else { + w.write("false") + } + case int: + w.write("%d", v) + case float32: + w.write("%v", dec(v)) + case float64: + w.write("%v", dec(v)) + case string: + v = strings.Replace(v, `\`, `\\`, -1) + v = strings.Replace(v, `(`, `\(`, -1) + v = strings.Replace(v, `)`, `\)`, -1) + w.write("(%v)", v) + case pdfRef: + w.write("%v 0 R", v) + case pdfName, pdfFilter: + w.write("/%v", v) + case pdfArray: + w.write("[") + for j, val := range v { + if j != 0 { + w.write(" ") + } + w.writeVal(val) + } + w.write("]") + case pdfDict: + w.write("<<") + if val, ok := v["Type"]; ok { + w.write("/Type") + if pdfValContinuesName(val) { + w.write(" ") + } + w.writeVal(val) + } + if val, ok := v["Subtype"]; ok { + w.write("/Subtype") + if pdfValContinuesName(val) { + w.write(" ") + } + w.writeVal(val) + } + keys := []string{} + for key := range v { + if key != "Type" && key != "Subtype" { + keys = append(keys, string(key)) + } + } + sort.Strings(keys) + for _, key := range keys { + w.writeVal(pdfName(key)) + if pdfValContinuesName(v[pdfName(key)]) { + w.write(" ") + } + w.writeVal(v[pdfName(key)]) + } + w.write(">>") + case pdfStream: + if v.dict == nil { + v.dict = pdfDict{} + } + + filters := []pdfFilter{} + if filter, ok := v.dict["Filter"].(pdfFilter); ok { + filters = append(filters, filter) + } else if filterArray, ok := v.dict["Filter"].(pdfArray); ok { + for i := len(filterArray) - 1; i >= 0; i-- { + if filter, ok := filterArray[i].(pdfFilter); ok { + filters = append(filters, filter) + } + } + } + + b := v.stream + for _, filter := range filters { + var b2 bytes.Buffer + switch filter { + case pdfFilterASCII85: + w := ascii85.NewEncoder(&b2) + w.Write(b) + w.Close() + fmt.Fprintf(&b2, "~>") + b = b2.Bytes() + case pdfFilterFlate: + w := zlib.NewWriter(&b2) + w.Write(b) + w.Close() + b = b2.Bytes() + default: + // assume already in the right format + } + } + + v.dict["Length"] = len(b) + w.writeVal(v.dict) + w.write("stream\n") + w.writeBytes(b) + w.write("\nendstream\n") + default: + panic(fmt.Sprintf("unknown PDF type %T", i)) + } +} + +func (w *pdfWriter) writeObject(val interface{}) pdfRef { + // newlines before and after obj and endobj are required by PDF/A + w.objOffsets = append(w.objOffsets, w.pos) + w.write("%v 0 obj\n", len(w.objOffsets)) + w.writeVal(val) + w.write("\nendobj\n") + return pdfRef(len(w.objOffsets)) +} + +func standardFontName(sty *rich.Style) string { + name := "Helvetica" + switch sty.Family { + case rich.SansSerif: + name = "Helvetica" + case rich.Serif: + name = "Times" + case rich.Monospace: + name = "Courier" + case rich.Cursive: + name = "ZapfChancery" + case rich.Math: + name = "Symbol" + case rich.Emoji: + name = "ZapfDingbats" + } + if sty.Weight > rich.Medium { + name += "-Bold" + if sty.Slant == rich.Italic { + name += "Oblique" + } + } else { + if sty.Slant == rich.Italic { + name += "-Oblique" + } + } + return name +} + +func (w *pdfWriter) getFont(sty *rich.Style, tsty *text.Style) pdfRef { + if sty.Family != rich.Custom { + stdFont := standardFontName(sty) + if ref, ok := w.fontsStd[stdFont]; ok { + return ref + } + + dict := pdfDict{ + "Type": pdfName("Font"), + "Subtype": pdfName("Type1"), + "BaseFont": pdfName(stdFont), + "Encoding": pdfName("WinAnsiEncoding"), + } + ref := w.writeObject(dict) + w.fontsStd[stdFont] = ref + return ref + } + // todo: deal with custom + /* + fonts := w.fontsH + if vertical { + fonts = w.fontsV + } + if ref, ok := fonts[font]; ok { + return ref + } + w.objOffsets = append(w.objOffsets, 0) + ref := pdfRef(len(w.objOffsets)) + fonts[font] = ref + w.fontSubset[font] = ppath.NewFontSubsetter() + return ref + */ + return 0 +} + +// Close finished the document. +func (w *pdfWriter) Close() error { + // TODO: support cross reference table streams and compressed objects for all dicts + if w.page != nil { + w.pages = append(w.pages, w.page.writePage(pdfRef(3))) + } + + kids := pdfArray{} + for _, page := range w.pages { + kids = append(kids, page) + } + + // write fonts + // w.writeFonts(w.fontsH, false) + // w.writeFonts(w.fontsV, false) + + // document catalog + catalog := pdfDict{ + "Type": pdfName("Catalog"), + "Pages": pdfRef(3), + // TODO: add metadata? + } + + // document info + info := pdfDict{ + "Producer": "tdewolff/canvas", + "CreationDate": time.Now().Format("D:20060102150405Z0700"), + } + + encode := func(s string) string { + // TODO: make clean + ascii := true + for _, r := range s { + if 0x80 <= r { + ascii = false + break + } + } + if ascii { + return s + } + + rs := utf16.Encode([]rune(s)) + b := make([]byte, 2+2*len(rs)) + b[0] = 254 + b[1] = 255 + for i, r := range rs { + b[2+2*i+0] = byte(r >> 8) + b[2+2*i+1] = byte(r & 0x00FF) + } + return string(b) + } + if w.title != "" { + info["Title"] = encode(w.title) + } + if w.subject != "" { + info["Subject"] = encode(w.subject) + } + if w.keywords != "" { + info["Keywords"] = encode(w.keywords) + } + if w.author != "" { + info["Author"] = encode(w.author) + } + if w.creator != "" { + info["Creator"] = encode(w.creator) + } + if w.lang != "" { + catalog["Lang"] = encode(w.creator) + } + + // document catalog + w.objOffsets[0] = w.pos + w.write("%v 0 obj\n", 1) + w.writeVal(catalog) + w.write("\nendobj\n") + + // document info + w.objOffsets[1] = w.pos + w.write("%v 0 obj\n", 2) + w.writeVal(info) + w.write("\nendobj\n") + + // page tree + w.objOffsets[2] = w.pos + w.write("%v 0 obj\n", 3) + w.writeVal(pdfDict{ + "Type": pdfName("Pages"), + "Kids": pdfArray(kids), + "Count": len(kids), + }) + w.write("\nendobj\n") + + xrefOffset := w.pos + w.write("xref\n0 %d\n0000000000 65535 f \n", len(w.objOffsets)+1) + for _, objOffset := range w.objOffsets { + w.write("%010d 00000 n \n", objOffset) + } + w.write("trailer\n") + w.writeVal(pdfDict{ + "Root": pdfRef(1), + "Size": len(w.objOffsets) + 1, + "Info": pdfRef(2), + // TODO: write document ID + }) + w.write("\nstartxref\n%v\n%%%%EOF\n", xrefOffset) + return w.err +} + +// NewPage starts a new page. +func (w *pdfWriter) NewPage(width, height float32) *pdfPageWriter { + if w.page != nil { + w.pages = append(w.pages, w.page.writePage(pdfRef(3))) + } + + // for defaults see https://help.adobe.com/pdfl_sdk/15/PDFL_SDK_HTMLHelp/PDFL_SDK_HTMLHelp/API_References/PDFL_API_Reference/PDFEdit_Layer/General.html#_t_PDEGraphicState + w.page = &pdfPageWriter{ + Buffer: &bytes.Buffer{}, + pdf: w, + width: width, + height: height, + resources: pdfDict{}, + graphicsStates: map[float32]pdfName{}, + inTextObject: false, + textPosition: math32.Identity2(), + textCharSpace: 0.0, + textRenderMode: 0, + } + w.page.style.Defaults() + + m := math32.Scale2D(ptPerMm, ptPerMm) + fmt.Fprintf(w.page, " %s cm", mat2(m)) + return w.page +} + +const mmPerPt = 25.4 / 72.0 +const ptPerMm = 72 / 25.4 + +type dec float32 + +func (f dec) String() string { + s := fmt.Sprintf("%.*f", 5, f) // precision + // s = string(minify.Decimal([]byte(s), canvas.Precision)) + if dec(math.MaxInt32) < f || f < dec(math.MinInt32) { + if i := strings.IndexByte(s, '.'); i == -1 { + s += ".0" + } + } + return s +} + +// mat2 returns matrix components as a string +func mat2(m math32.Matrix2) string { + return fmt.Sprintf("%v %v %v %v %v %v", dec(m.XX), dec(m.XY), dec(m.YX), dec(m.YY), dec(m.X0), dec(m.Y0)) +} diff --git a/paint/renderers/htmlcanvas/path.go b/paint/renderers/htmlcanvas/path.go index 4bd676079a..11bac68dad 100644 --- a/paint/renderers/htmlcanvas/path.go +++ b/paint/renderers/htmlcanvas/path.go @@ -58,9 +58,14 @@ func (rs *Renderer) RenderPath(pt *render.Path) { } if style.HasStroke() { scale := math32.Sqrt(math32.Abs(pt.Context.Transform.Det())) - // note: this is a hack to get the effect of [ppath.VectorEffectNonScalingStroke] - style.Stroke.Width.Dots /= scale - rs.setStroke(&style.Stroke) + if scale != 1 { + // note: this is a hack to get the effect of [ppath.VectorEffectNonScalingStroke] + stk := style.Stroke + stk.Width.Dots /= scale + rs.setStroke(&stk) + } else { + rs.setStroke(&style.Stroke) + } rs.ctx.Call("stroke") } } diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go new file mode 100644 index 0000000000..bcfb51d32b --- /dev/null +++ b/paint/renderers/pdfrender/pdfrender.go @@ -0,0 +1,224 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pdfrender + +import ( + "bytes" + "image" + "maps" + "strconv" + + "codeberg.org/go-pdf/fpdf" + "cogentcore.org/core/base/iox/imagex" + "cogentcore.org/core/base/reflectx" + "cogentcore.org/core/base/stack" + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/core" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint" + "cogentcore.org/core/paint/pimage" + "cogentcore.org/core/paint/render" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/shaped/shapers/shapedgt" +) + +// Renderer is the PDF renderer. +type Renderer struct { + size math32.Vector2 + + PDF *fpdf.Fpdf + + // lyStack is a stack of layers used while building the pdf (int layer id) + lyStack stack.Stack[int] +} + +func New(size math32.Vector2) render.Renderer { + rs := &Renderer{} + rs.SetSize(units.UnitDot, size) + return rs +} + +func (rs *Renderer) Image() image.Image { + if rs.PDF == nil { + return nil + } + pc := rs.FPDf.Render(nil) + ir := paint.NewImageRenderer(rs.size) + ir.Render(pc.RenderDone()) + return ir.Image() +} + +func (rs *Renderer) Source() []byte { + if rs.PDF == nil { + return nil + } + var b bytes.Buffer + rs.FPDf.WriteXML(&b, true) + return b.Bytes() +} + +func (rs *Renderer) Size() (units.Units, math32.Vector2) { + return units.UnitDot, rs.size +} + +func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { + if rs.size == size { + return + } + rs.size = size +} + +// Render is the main rendering function. +func (rs *Renderer) Render(r render.Render) render.Renderer { + rs.PDF = fpdf.New("P", "mm", core.SystemSettings.PageSize.String(), ".") + rs.PDF.SetFont("Arial", "", 16) + rs.lyStack = nil + bg := rs.PDF.AddLayer("bg", true) + rs.PDF.BeginLayer(bg) + rs.lyStack.Push(bg) + for _, ri := range r { + switch x := ri.(type) { + case *render.Path: + rs.RenderPath(x) + case *pimage.Params: + rs.RenderImage(x) + case *render.Text: + rs.RenderText(x) + case *render.ContextPush: + rs.PushContext(x) + case *render.ContextPop: + rs.PopContext(x) + } + } + rs.PDF.EndLayer() + return rs +} + +func (rs *Renderer) PushLayer() int { + cg := rs.lyStack.Peek() + nm := strconv.Itoa(cg + 1) + g := rs.PDF.AddLayer(nm) + rs.PDF.BeginLayer(g) + rs.lyStack.Push(g) + return g +} + +func (rs *Renderer) PopLayer() int { + cg := rs.lyStack.Pop() + rs.PDF.EndLayer() +} + +func (rs *Renderer) RenderPath(pt *render.Path) { + p := pt.Path + pc := &pt.Context + + closed := false + data := p.Copy().Transform(pc.Transform).ToPDF() + if 1 < len(data) && data[len(data)-1] == 'h' { + data = data[:len(data)-2] + closed = true + } + + cg := rs.lyStack.Peek() + sp := fpdf.NewPath(cg) + sp.Data = p.Clone() + props := map[string]any{} + pt.Context.Style.GetProperties(props) + if !pc.Transform.IsIdentity() { + props["transform"] = pc.Transform.String() + } + sp.Properties = props + // rs.Scanner.SetClip(pc.Bounds.Rect.ToRect()) +} + +func (rs *Renderer) PushContext(pt *render.ContextPush) { + pc := &pt.Context + g := rs.PushLayer() + g.Paint.Transform = pc.Transform +} + +func (rs *Renderer) PopContext(pt *render.ContextPop) { + rs.PopLayer() +} + +func (rs *Renderer) RenderText(pt *render.Text) { + pc := &pt.Context + cg := rs.lyStack.Peek() + tg := fpdf.NewLayer(cg) + props := map[string]any{} + pt.Context.Style.GetProperties(props) + if !pc.Transform.IsIdentity() { + props["transform"] = pc.Transform.String() + } + pos := pt.Position + tx := pt.Text.Source + txt := tx.Join() + for li := range pt.Text.Lines { + ln := &pt.Text.Lines[li] + lpos := pos.Add(ln.Offset) + rpos := lpos + for ri := range ln.Runs { + run := ln.Runs[ri].(*shapedgt.Run) + rs := run.Runes().Start + re := run.Runes().End + si, _, _ := tx.Index(rs) + sty, _ := tx.Span(si) + rtxt := txt[rs:re] + + st := fpdf.NewText(tg) + st.Text = string(rtxt) + rprops := maps.Clone(props) + if pc.Style.UnitContext.DPI != 160 { + sty.Size *= pc.Style.UnitContext.DPI / 160 + } + pt.Context.Style.Text.ToProperties(sty, rprops) + rprops["x"] = reflectx.ToString(rpos.X) + rprops["y"] = reflectx.ToString(rpos.Y) + st.Pos = rpos + st.Properties = rprops + + rpos.X += run.Advance() + } + } +} + +func (rs *Renderer) RenderImage(pr *pimage.Params) { + usrc := imagex.Unwrap(pr.Source) + umask := imagex.Unwrap(pr.Mask) + cg := rs.lyStack.Peek() + + nilSrc := usrc == nil + if r, ok := usrc.(*image.RGBA); ok && r == nil { + nilSrc = true + } + if pr.Rect == (image.Rectangle{}) { + pr.Rect = image.Rectangle{Max: rs.size.ToPoint()} + } + + // todo: handle masks! + + // Fast path for [image.Uniform] + if u, ok := usrc.(*image.Uniform); nilSrc || ok && umask == nil { + r := fpdf.NewRect(cg) + r.Pos = math32.FromPoint(pr.Rect.Min) + r.Size = math32.FromPoint(pr.Rect.Size()) + r.SetProperty("fill", colors.AsHex(u.C)) + return + } + + if gr, ok := usrc.(gradient.Gradient); ok { + _ = gr + // todo: handle: + return + } + + sz := pr.Rect.Size() + + simg := fpdf.NewImage(cg) + simg.SetImage(usrc, float32(sz.X), float32(sz.Y)) + simg.Pos = math32.FromPoint(pr.Rect.Min) + // todo: ViewBox? +} diff --git a/text/rich/style.go b/text/rich/style.go index 430ba8ccea..82caa6b0d5 100644 --- a/text/rich/style.go +++ b/text/rich/style.go @@ -140,7 +140,7 @@ const ( Serif // Monospace fonts have all glyphs with he same fixed width. - // Example monospace fonts include Fira Mono, DejaVu Sans Mono, + // Example monospace fonts include Courier, Fira Mono, DejaVu Sans Mono, // Menlo, Consolas, Liberation Mono, Monaco, and Lucida Console. Monospace From 5891134045542950db19893d4c641da0bc618abf Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 2 Oct 2025 10:12:10 +0200 Subject: [PATCH 02/99] pdf: pdfrender updated, layers supported --- paint/paint_test.go | 12 ++ paint/ppath/pdf/layer.go | 104 ++++++++++++++++ paint/ppath/pdf/pdf.go | 23 ++++ paint/ppath/pdf/writer.go | 9 +- paint/renderers/pdfrender/pdfrender.go | 163 +++++++++++-------------- paint/renderers/renderers.go | 2 + paint/renderers/svgrender/svgrender.go | 6 +- paint/state.go | 4 + 8 files changed, 226 insertions(+), 97 deletions(-) create mode 100644 paint/ppath/pdf/layer.go diff --git a/paint/paint_test.go b/paint/paint_test.go index 90ad3a2010..10a223c77d 100644 --- a/paint/paint_test.go +++ b/paint/paint_test.go @@ -37,9 +37,12 @@ func RunTest(t *testing.T, nm string, width int, height int, f func(pc *Painter) rend := pc.RenderDone() ir := NewImageRenderer(size) sv := NewSVGRenderer(size) + pd := NewPDFRenderer(size) ir.Render(rend) sv.Render(rend) + pd.Render(rend) imagex.Assert(t, ir.Image(), nm) + svdir := filepath.Join("testdata", "svg") dp, fno := filepath.Split(nm) if dp != "" { @@ -50,6 +53,15 @@ func RunTest(t *testing.T, nm string, width int, height int, f func(pc *Painter) svfnm := filepath.Join(svdir, nm) + ".svg" err := os.WriteFile(svfnm, sv.Source(), 0666) assert.NoError(t, err) + + pddir := filepath.Join("testdata", "pdf") + if dp != "" { + pddir = filepath.Join(pddir, dp) + } + os.MkdirAll(pddir, 0777) + pdfnm := filepath.Join(pddir, nm) + ".pdf" + err = os.WriteFile(pdfnm, pd.Source(), 0666) + assert.NoError(t, err) } func TestRender(t *testing.T) { diff --git a/paint/ppath/pdf/layer.go b/paint/ppath/pdf/layer.go new file mode 100644 index 0000000000..f65817c53f --- /dev/null +++ b/paint/ppath/pdf/layer.go @@ -0,0 +1,104 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from codeberg.org/go-pdf/fpdf +// Copyright (c) 2023 The go-pdf Authors and Kurt Jung, +// under an MIT License. + +package pdf + +import "fmt" + +// pdfLayer is one layer +type pdfLayer struct { + name string + visible bool + objNum int // object number +} + +// pdfLayers is all the layers +type pdfLayers struct { + list []pdfLayer + currentLayer int + openLayerPane bool +} + +func (w *pdfWriter) layerInit() { + w.layers.list = make([]pdfLayer, 0) + w.layers.currentLayer = -1 + w.layers.openLayerPane = false +} + +// AddLayer defines a layer that can be shown or hidden when the document is +// displayed. name specifies the layer name that the document reader will +// display in the layer list. visible specifies whether the layer will be +// initially visible. The return value is an integer ID that is used in a call +// to BeginLayer(). +func (w *pdfWriter) AddLayer(name string, visible bool) (layerID int) { + layerID = len(w.layers.list) + w.layers.list = append(w.layers.list, pdfLayer{name: name, visible: visible}) + return +} + +// BeginLayer is called to begin adding content to the specified layer. All +// content added to the page between a call to BeginLayer and a call to +// EndLayer is added to the layer specified by id. See AddLayer for more +// details. +func (w *pdfWriter) BeginLayer(id int) { + w.EndLayer() + if id >= 0 && id < len(w.layers.list) { + w.write("/OC /OC%d BDC", id) + w.layers.currentLayer = id + } +} + +// EndLayer is called to stop adding content to the currently active layer. See +// BeginLayer for more details. +func (w *pdfWriter) EndLayer() { + if w.layers.currentLayer >= 0 { + w.write("EMC") + w.layers.currentLayer = -1 + } +} + +// OpenLayerPane advises the document reader to open the layer pane when the +// document is initially displayed. +func (w *pdfWriter) OpenLayerPane() { + w.layers.openLayerPane = true +} + +func (w *pdfWriter) writeLayers() { + for _, l := range w.layers.list { + w.writeObject(&l) + } +} + +func (w *pdfWriter) writeLayerResourceDict() { + if len(w.layers.list) == 0 { + return + } + w.write("/Properties <<") + for j, layer := range w.layers.list { + w.write("/OC%d %d 0 R", j, layer.objNum) + } + w.write(">>") +} + +func (w *pdfWriter) writeLayerCatalog() { + if len(w.layers.list) == 0 { + return + } + onStr := "" + offStr := "" + for _, layer := range w.layers.list { + onStr += fmt.Sprintf("%d 0 R ", layer.objNum) + if !layer.visible { + offStr += fmt.Sprintf("%d 0 R ", layer.objNum) + } + } + w.write("/OCProperties <>>>", onStr, offStr, onStr) + if w.layers.openLayerPane { + w.write("/PageMode /UseOC") + } +} diff --git a/paint/ppath/pdf/pdf.go b/paint/ppath/pdf/pdf.go index 9c055c4b60..d473fd1add 100644 --- a/paint/ppath/pdf/pdf.go +++ b/paint/ppath/pdf/pdf.go @@ -93,6 +93,29 @@ func (r *PDF) Size() (float32, float32) { return r.width, r.height } +// AddLayer defines a layer that can be shown or hidden when the document is +// displayed. name specifies the layer name that the document reader will +// display in the layer list. visible specifies whether the layer will be +// initially visible. The return value is an integer ID that is used in a call +// to BeginLayer(). +func (r *PDF) AddLayer(name string, visible bool) (layerID int) { + return r.w.pdf.AddLayer(name, visible) +} + +// BeginLayer is called to begin adding content to the specified layer. All +// content added to the page between a call to BeginLayer and a call to +// EndLayer is added to the layer specified by id. See AddLayer for more +// details. +func (r *PDF) BeginLayer(id int) { + r.w.pdf.BeginLayer(id) +} + +// EndLayer is called to stop adding content to the currently active layer. See +// BeginLayer for more details. +func (r *PDF) EndLayer() { + r.w.pdf.EndLayer() +} + // RenderPath renders a path to the canvas using a style and a transformation matrix. func (r *PDF) RenderPath(path ppath.Path, style *styles.Paint, m math32.Matrix2) { // PDFs don't support the arcs joiner, miter joiner (not clipped), diff --git a/paint/ppath/pdf/writer.go b/paint/ppath/pdf/writer.go index 9d3cc802e0..a14f54f800 100644 --- a/paint/ppath/pdf/writer.go +++ b/paint/ppath/pdf/writer.go @@ -43,6 +43,7 @@ type pdfWriter struct { // fontsH map[*text.Font]pdfRef // fontsV map[*text.Font]pdfRef images map[image.Image]pdfRef + layers pdfLayers compress bool subset bool title string @@ -65,6 +66,7 @@ func newPDFWriter(writer io.Writer) *pdfWriter { compress: false, subset: true, } + w.layerInit() w.write("%%PDF-1.7\n%%Ŧǟċơ\n") return w @@ -256,6 +258,9 @@ func (w *pdfWriter) writeVal(i interface{}) { w.write("stream\n") w.writeBytes(b) w.write("\nendstream\n") + case *pdfLayer: + v.objNum = len(w.objOffsets) + w.write("<>", pdfName(v.name)) default: panic(fmt.Sprintf("unknown PDF type %T", i)) } @@ -359,7 +364,7 @@ func (w *pdfWriter) Close() error { // document info info := pdfDict{ - "Producer": "tdewolff/canvas", + "Producer": "cogentcore/pdf", "CreationDate": time.Now().Format("D:20060102150405Z0700"), } @@ -409,12 +414,14 @@ func (w *pdfWriter) Close() error { w.objOffsets[0] = w.pos w.write("%v 0 obj\n", 1) w.writeVal(catalog) + w.writeLayerCatalog() w.write("\nendobj\n") // document info w.objOffsets[1] = w.pos w.write("%v 0 obj\n", 2) w.writeVal(info) + w.writeLayerResourceDict() w.write("\nendobj\n") // page tree diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index bcfb51d32b..1bb5c6a640 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -7,29 +7,26 @@ package pdfrender import ( "bytes" "image" - "maps" "strconv" - "codeberg.org/go-pdf/fpdf" "cogentcore.org/core/base/iox/imagex" - "cogentcore.org/core/base/reflectx" "cogentcore.org/core/base/stack" - "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" - "cogentcore.org/core/core" "cogentcore.org/core/math32" - "cogentcore.org/core/paint" "cogentcore.org/core/paint/pimage" + "cogentcore.org/core/paint/ppath/pdf" "cogentcore.org/core/paint/render" "cogentcore.org/core/styles/units" - "cogentcore.org/core/text/shaped/shapers/shapedgt" ) // Renderer is the PDF renderer. type Renderer struct { - size math32.Vector2 + size math32.Vector2 + units units.Units - PDF *fpdf.Fpdf + PDF *pdf.PDF + + buff *bytes.Buffer // lyStack is a stack of layers used while building the pdf (int layer id) lyStack stack.Stack[int] @@ -42,39 +39,33 @@ func New(size math32.Vector2) render.Renderer { } func (rs *Renderer) Image() image.Image { - if rs.PDF == nil { - return nil - } - pc := rs.FPDf.Render(nil) - ir := paint.NewImageRenderer(rs.size) - ir.Render(pc.RenderDone()) - return ir.Image() + return nil // can't generate an image } func (rs *Renderer) Source() []byte { - if rs.PDF == nil { + if rs.buff == nil { return nil } - var b bytes.Buffer - rs.FPDf.WriteXML(&b, true) - return b.Bytes() + return rs.buff.Bytes() } func (rs *Renderer) Size() (units.Units, math32.Vector2) { - return units.UnitDot, rs.size + return rs.units, rs.size } func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { - if rs.size == size { + if rs.units == un && rs.size == size { return } + rs.units = un rs.size = size } // Render is the main rendering function. func (rs *Renderer) Render(r render.Render) render.Renderer { - rs.PDF = fpdf.New("P", "mm", core.SystemSettings.PageSize.String(), ".") - rs.PDF.SetFont("Arial", "", 16) + rs.buff = &bytes.Buffer{} + // todo: convert size to mm + rs.PDF = pdf.New(rs.buff, rs.size.X, rs.size.Y) rs.lyStack = nil bg := rs.PDF.AddLayer("bg", true) rs.PDF.BeginLayer(bg) @@ -94,13 +85,14 @@ func (rs *Renderer) Render(r render.Render) render.Renderer { } } rs.PDF.EndLayer() + rs.PDF.Close() return rs } func (rs *Renderer) PushLayer() int { cg := rs.lyStack.Peek() nm := strconv.Itoa(cg + 1) - g := rs.PDF.AddLayer(nm) + g := rs.PDF.AddLayer(nm, true) rs.PDF.BeginLayer(g) rs.lyStack.Push(g) return g @@ -109,35 +101,17 @@ func (rs *Renderer) PushLayer() int { func (rs *Renderer) PopLayer() int { cg := rs.lyStack.Pop() rs.PDF.EndLayer() + return cg } func (rs *Renderer) RenderPath(pt *render.Path) { p := pt.Path pc := &pt.Context - - closed := false - data := p.Copy().Transform(pc.Transform).ToPDF() - if 1 < len(data) && data[len(data)-1] == 'h' { - data = data[:len(data)-2] - closed = true - } - - cg := rs.lyStack.Peek() - sp := fpdf.NewPath(cg) - sp.Data = p.Clone() - props := map[string]any{} - pt.Context.Style.GetProperties(props) - if !pc.Transform.IsIdentity() { - props["transform"] = pc.Transform.String() - } - sp.Properties = props - // rs.Scanner.SetClip(pc.Bounds.Rect.ToRect()) + rs.PDF.RenderPath(p, &pc.Style, pc.Transform) } func (rs *Renderer) PushContext(pt *render.ContextPush) { - pc := &pt.Context - g := rs.PushLayer() - g.Paint.Transform = pc.Transform + rs.PushLayer() // note: does not set transform.. } func (rs *Renderer) PopContext(pt *render.ContextPop) { @@ -145,50 +119,49 @@ func (rs *Renderer) PopContext(pt *render.ContextPop) { } func (rs *Renderer) RenderText(pt *render.Text) { - pc := &pt.Context - cg := rs.lyStack.Peek() - tg := fpdf.NewLayer(cg) - props := map[string]any{} - pt.Context.Style.GetProperties(props) - if !pc.Transform.IsIdentity() { - props["transform"] = pc.Transform.String() - } - pos := pt.Position - tx := pt.Text.Source - txt := tx.Join() - for li := range pt.Text.Lines { - ln := &pt.Text.Lines[li] - lpos := pos.Add(ln.Offset) - rpos := lpos - for ri := range ln.Runs { - run := ln.Runs[ri].(*shapedgt.Run) - rs := run.Runes().Start - re := run.Runes().End - si, _, _ := tx.Index(rs) - sty, _ := tx.Span(si) - rtxt := txt[rs:re] - - st := fpdf.NewText(tg) - st.Text = string(rtxt) - rprops := maps.Clone(props) - if pc.Style.UnitContext.DPI != 160 { - sty.Size *= pc.Style.UnitContext.DPI / 160 - } - pt.Context.Style.Text.ToProperties(sty, rprops) - rprops["x"] = reflectx.ToString(rpos.X) - rprops["y"] = reflectx.ToString(rpos.Y) - st.Pos = rpos - st.Properties = rprops - - rpos.X += run.Advance() - } - } + // pc := &pt.Context + // cg := rs.lyStack.Peek() + // tg := fpdf.NewLayer(cg) + // props := map[string]any{} + // pt.Context.Style.GetProperties(props) + // if !pc.Transform.IsIdentity() { + // props["transform"] = pc.Transform.String() + // } + // pos := pt.Position + // tx := pt.Text.Source + // txt := tx.Join() + // for li := range pt.Text.Lines { + // ln := &pt.Text.Lines[li] + // lpos := pos.Add(ln.Offset) + // rpos := lpos + // for ri := range ln.Runs { + // run := ln.Runs[ri].(*shapedgt.Run) + // rs := run.Runes().Start + // re := run.Runes().End + // si, _, _ := tx.Index(rs) + // sty, _ := tx.Span(si) + // rtxt := txt[rs:re] + // + // st := fpdf.NewText(tg) + // st.Text = string(rtxt) + // rprops := maps.Clone(props) + // if pc.Style.UnitContext.DPI != 160 { + // sty.Size *= pc.Style.UnitContext.DPI / 160 + // } + // pt.Context.Style.Text.ToProperties(sty, rprops) + // rprops["x"] = reflectx.ToString(rpos.X) + // rprops["y"] = reflectx.ToString(rpos.Y) + // st.Pos = rpos + // st.Properties = rprops + // + // rpos.X += run.Advance() + // } + // } } func (rs *Renderer) RenderImage(pr *pimage.Params) { usrc := imagex.Unwrap(pr.Source) umask := imagex.Unwrap(pr.Mask) - cg := rs.lyStack.Peek() nilSrc := usrc == nil if r, ok := usrc.(*image.RGBA); ok && r == nil { @@ -202,10 +175,12 @@ func (rs *Renderer) RenderImage(pr *pimage.Params) { // Fast path for [image.Uniform] if u, ok := usrc.(*image.Uniform); nilSrc || ok && umask == nil { - r := fpdf.NewRect(cg) - r.Pos = math32.FromPoint(pr.Rect.Min) - r.Size = math32.FromPoint(pr.Rect.Size()) - r.SetProperty("fill", colors.AsHex(u.C)) + _ = u + // todo: draw a box + // r := fpdf.NewRect(cg) + // r.Pos = math32.FromPoint(pr.Rect.Min) + // r.Size = math32.FromPoint(pr.Rect.Size()) + // r.SetProperty("fill", colors.AsHex(u.C)) return } @@ -215,10 +190,8 @@ func (rs *Renderer) RenderImage(pr *pimage.Params) { return } - sz := pr.Rect.Size() - - simg := fpdf.NewImage(cg) - simg.SetImage(usrc, float32(sz.X), float32(sz.Y)) - simg.Pos = math32.FromPoint(pr.Rect.Min) - // todo: ViewBox? + // sz := pr.Rect.Size() + m := math32.Translate2D(float32(pr.Rect.Min.X), float32(pr.Rect.Min.Y)) + rs.PDF.RenderImage(usrc, m) + // simg.Pos = math32.FromPoint(pr.Rect.Min) } diff --git a/paint/renderers/renderers.go b/paint/renderers/renderers.go index 99e5afadd1..120dcce479 100644 --- a/paint/renderers/renderers.go +++ b/paint/renderers/renderers.go @@ -6,10 +6,12 @@ package renderers import ( "cogentcore.org/core/paint" + "cogentcore.org/core/paint/renderers/pdfrender" "cogentcore.org/core/paint/renderers/svgrender" _ "cogentcore.org/core/text/shaped/shapers" ) func init() { paint.NewSVGRenderer = svgrender.New + paint.NewPDFRenderer = pdfrender.New } diff --git a/paint/renderers/svgrender/svgrender.go b/paint/renderers/svgrender/svgrender.go index 399823492d..ae27a78501 100644 --- a/paint/renderers/svgrender/svgrender.go +++ b/paint/renderers/svgrender/svgrender.go @@ -188,7 +188,11 @@ func (rs *Renderer) RenderImage(pr *pimage.Params) { r := svg.NewRect(cg) r.Pos = math32.FromPoint(pr.Rect.Min) r.Size = math32.FromPoint(pr.Rect.Size()) - r.SetProperty("fill", colors.AsHex(u.C)) + if ok { + r.SetProperty("fill", colors.AsHex(u.C)) + } else { + r.SetProperty("fill", colors.Transparent) + } return } diff --git a/paint/state.go b/paint/state.go index ba705b5a23..c7121c1f8f 100644 --- a/paint/state.go +++ b/paint/state.go @@ -28,6 +28,10 @@ var ( // NewSVGRenderer returns a structured SVG renderer that can // generate an SVG vector graphics document from painter content. NewSVGRenderer func(size math32.Vector2) render.Renderer + + // NewPDFRenderer returns a PDF renderer that can + // generate a PDF document from painter content. + NewPDFRenderer func(size math32.Vector2) render.Renderer ) // RenderToImage is a convenience function that renders the current From 52a83e0a259053e9244f180791938048635f6ff7 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 2 Oct 2025 12:02:48 +0200 Subject: [PATCH 03/99] pdf: units converting to points properly, top-down coordinate system established, all good --- paint/paint_test.go | 2 +- paint/ppath/pdf/page.go | 8 ++++---- paint/ppath/pdf/pdf.go | 8 +++++--- paint/ppath/pdf/writer.go | 25 ++++++++++++------------ paint/renderers/htmlcanvas/htmlcanvas.go | 7 ++++--- paint/renderers/pdfrender/pdfrender.go | 21 +++++++++++--------- paint/state.go | 3 ++- styles/units/context.go | 12 +++++++++++- styles/units/units_test.go | 3 +++ 9 files changed, 55 insertions(+), 34 deletions(-) diff --git a/paint/paint_test.go b/paint/paint_test.go index 10a223c77d..80a472d6c5 100644 --- a/paint/paint_test.go +++ b/paint/paint_test.go @@ -37,7 +37,7 @@ func RunTest(t *testing.T, nm string, width int, height int, f func(pc *Painter) rend := pc.RenderDone() ir := NewImageRenderer(size) sv := NewSVGRenderer(size) - pd := NewPDFRenderer(size) + pd := NewPDFRenderer(size, &pc.Stack[0].Style.UnitContext) ir.Render(rend) sv.Render(rend) pd.Render(rend) diff --git a/paint/ppath/pdf/page.go b/paint/ppath/pdf/page.go index dd095d6249..cecac5c3f8 100644 --- a/paint/ppath/pdf/page.go +++ b/paint/ppath/pdf/page.go @@ -55,7 +55,7 @@ func (w *pdfPageWriter) writePage(parent pdfRef) pdfRef { page := pdfDict{ "Type": pdfName("Page"), "Parent": parent, - "MediaBox": pdfArray{0.0, 0.0, w.width * ptPerMm, w.height * ptPerMm}, + "MediaBox": pdfArray{0.0, 0.0, w.width, w.height}, "Resources": w.resources, "Group": pdfDict{ "Type": pdfName("Group"), @@ -77,7 +77,7 @@ func (w *pdfPageWriter) AddURIAction(uri string, rect math32.Box2) { "Type": pdfName("Annot"), "Subtype": pdfName("Link"), "Border": pdfArray{0, 0, 0}, - "Rect": pdfArray{rect.Min.X * ptPerMm, rect.Min.Y * ptPerMm, rect.Max.X * ptPerMm, rect.Max.Y * ptPerMm}, + "Rect": pdfArray{rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y}, "Contents": uri, "A": pdfDict{ "S": pdfName("URI"), @@ -539,12 +539,12 @@ func (w *pdfPageWriter) getPattern(gradient ppath.Gradient) pdfName { } if g, ok := gradient.(*ppath.LinearGradient); ok { shading["ShadingType"] = 2 - shading["Coords"] = pdfArray{g.Start.X * ptPerMm, g.Start.Y * ptPerMm, g.End.X * ptPerMm, g.End.Y * ptPerMm} + shading["Coords"] = pdfArray{g.Start.X, g.Start.Y, g.End.X, g.End.Y} shading["Function"] = patternStopsFunction(g.Stops) shading["Extend"] = pdfArray{true, true} } else if g, ok := gradient.(*ppath.RadialGradient); ok { shading["ShadingType"] = 3 - shading["Coords"] = pdfArray{g.C0.X * ptPerMm, g.C0.Y * ptPerMm, g.R0 * ptPerMm, g.C1.X * ptPerMm, g.C1.Y * ptPerMm, g.R1 * ptPerMm} + shading["Coords"] = pdfArray{g.C0.X, g.C0.Y, g.R0, g.C1.X, g.C1.Y, g.R1} shading["Function"] = patternStopsFunction(g.Stops) shading["Extend"] = pdfArray{true, true} } diff --git a/paint/ppath/pdf/pdf.go b/paint/ppath/pdf/pdf.go index d473fd1add..0bff794da4 100644 --- a/paint/ppath/pdf/pdf.go +++ b/paint/ppath/pdf/pdf.go @@ -14,6 +14,7 @@ import ( "cogentcore.org/core/math32" "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" + "cogentcore.org/core/styles/units" "cogentcore.org/core/text/shaped" ) @@ -37,13 +38,14 @@ type PDF struct { } // New returns a portable document format (PDF) renderer. -func New(w io.Writer, width, height float32) *PDF { +// The size is in points. +func New(w io.Writer, width, height float32, un *units.Context) *PDF { // if opts == nil { // defaultOptions := DefaultOptions // opts = &defaultOptions // } - page := newPDFWriter(w).NewPage(width, height) + page := newPDFWriter(w, un).NewPage(width, height) // page.pdf.SetCompression(opts.Compress) // page.pdf.SetFontSubsetting(opts.SubsetFonts) return &PDF{ @@ -134,7 +136,7 @@ func (r *PDF) RenderPath(path ppath.Path, style *styles.Paint, m math32.Matrix2) scale := math32.Sqrt(math32.Abs(m.Det())) stk := style.Stroke stk.Width.Dots *= scale - stk.DashOffset, stk.Dashes = ppath.ScaleDash(stk.Width.Dots, stk.DashOffset, stk.Dashes) + stk.DashOffset, stk.Dashes = ppath.ScaleDash(scale, stk.DashOffset, stk.Dashes) // PDFs don't support connecting first and last dashes if path is closed, // so we move the start of the path if this is the case diff --git a/paint/ppath/pdf/writer.go b/paint/ppath/pdf/writer.go index a14f54f800..99357e4baa 100644 --- a/paint/ppath/pdf/writer.go +++ b/paint/ppath/pdf/writer.go @@ -21,6 +21,7 @@ import ( "unicode/utf16" "cogentcore.org/core/math32" + "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" "cogentcore.org/core/text/text" ) @@ -32,9 +33,10 @@ type pdfWriter struct { w io.Writer err error - pos int - objOffsets []int - pages []pdfRef + unitContext units.Context + pos int + objOffsets []int + pages []pdfRef page *pdfPageWriter fontsStd map[string]pdfRef @@ -54,11 +56,12 @@ type pdfWriter struct { lang string } -func newPDFWriter(writer io.Writer) *pdfWriter { +func newPDFWriter(writer io.Writer, un *units.Context) *pdfWriter { w := &pdfWriter{ - w: writer, - objOffsets: []int{0, 0, 0}, // catalog, metadata, page tree - fontsStd: map[string]pdfRef{}, + w: writer, + unitContext: *un, + objOffsets: []int{0, 0, 0}, // catalog, metadata, page tree + fontsStd: map[string]pdfRef{}, // fontSubset: map[*text.Font]*ppath.FontSubsetter{}, // fontsH: map[*text.Font]pdfRef{}, // fontsV: map[*text.Font]pdfRef{}, @@ -68,7 +71,7 @@ func newPDFWriter(writer io.Writer) *pdfWriter { } w.layerInit() - w.write("%%PDF-1.7\n%%Ŧǟċơ\n") + w.write("%%PDF-1.7\n") return w } @@ -471,14 +474,12 @@ func (w *pdfWriter) NewPage(width, height float32) *pdfPageWriter { } w.page.style.Defaults() - m := math32.Scale2D(ptPerMm, ptPerMm) + sc := w.unitContext.Convert(1, units.UnitDot, units.UnitPt) + m := math32.Translate2D(0, height).Scale(sc, -sc) fmt.Fprintf(w.page, " %s cm", mat2(m)) return w.page } -const mmPerPt = 25.4 / 72.0 -const ptPerMm = 72 / 25.4 - type dec float32 func (f dec) String() string { diff --git a/paint/renderers/htmlcanvas/htmlcanvas.go b/paint/renderers/htmlcanvas/htmlcanvas.go index 7eeb6f5d92..208218daaf 100644 --- a/paint/renderers/htmlcanvas/htmlcanvas.go +++ b/paint/renderers/htmlcanvas/htmlcanvas.go @@ -25,9 +25,10 @@ import ( // Renderer is an HTML canvas renderer. type Renderer struct { - Canvas js.Value - ctx js.Value - size math32.Vector2 + Canvas js.Value + ctx js.Value + size math32.Vector2 + unitContext units.Context // curRect is the rectangle of the current object. curRect image.Rectangle diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index 1bb5c6a640..19f0544567 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -21,8 +21,9 @@ import ( // Renderer is the PDF renderer. type Renderer struct { - size math32.Vector2 - units units.Units + size math32.Vector2 + sizeUnits units.Units + unitContext units.Context PDF *pdf.PDF @@ -32,8 +33,8 @@ type Renderer struct { lyStack stack.Stack[int] } -func New(size math32.Vector2) render.Renderer { - rs := &Renderer{} +func New(size math32.Vector2, un *units.Context) render.Renderer { + rs := &Renderer{unitContext: *un} rs.SetSize(units.UnitDot, size) return rs } @@ -50,22 +51,24 @@ func (rs *Renderer) Source() []byte { } func (rs *Renderer) Size() (units.Units, math32.Vector2) { - return rs.units, rs.size + return rs.sizeUnits, rs.size } func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { - if rs.units == un && rs.size == size { + if rs.sizeUnits == un && rs.size == size { return } - rs.units = un + rs.sizeUnits = un rs.size = size } // Render is the main rendering function. func (rs *Renderer) Render(r render.Render) render.Renderer { rs.buff = &bytes.Buffer{} - // todo: convert size to mm - rs.PDF = pdf.New(rs.buff, rs.size.X, rs.size.Y) + // pdf is in points + sx := rs.unitContext.Convert(float32(rs.size.X), rs.sizeUnits, units.UnitPt) + sy := rs.unitContext.Convert(float32(rs.size.Y), rs.sizeUnits, units.UnitPt) + rs.PDF = pdf.New(rs.buff, sx, sy, &rs.unitContext) rs.lyStack = nil bg := rs.PDF.AddLayer("bg", true) rs.PDF.BeginLayer(bg) diff --git a/paint/state.go b/paint/state.go index c7121c1f8f..732a93a07d 100644 --- a/paint/state.go +++ b/paint/state.go @@ -14,6 +14,7 @@ import ( "cogentcore.org/core/paint/render" "cogentcore.org/core/styles" "cogentcore.org/core/styles/sides" + "cogentcore.org/core/styles/units" ) var ( @@ -31,7 +32,7 @@ var ( // NewPDFRenderer returns a PDF renderer that can // generate a PDF document from painter content. - NewPDFRenderer func(size math32.Vector2) render.Renderer + NewPDFRenderer func(size math32.Vector2, un *units.Context) render.Renderer ) // RenderToImage is a convenience function that renders the current diff --git a/styles/units/context.go b/styles/units/context.go index 8363f3bc9e..5ac79e71db 100644 --- a/styles/units/context.go +++ b/styles/units/context.go @@ -177,11 +177,21 @@ func (uc *Context) Dots(un Units) float32 { return uc.DPI } -// ToDots converts value in given units into raw display pixels (dots in DPI) +// ToDots converts value in given units into raw display pixels (dots in DPI). func (uc *Context) ToDots(val float32, un Units) float32 { return val * uc.Dots(un) } +// FromDots converts value in dots to value in given units. +func (uc *Context) FromDots(val float32, un Units) float32 { + return val / uc.Dots(un) +} + +// Convert converts value in given from units to value in given to units. +func (uc *Context) Convert(val float32, from, to Units) float32 { + return val * (uc.Dots(from) / uc.Dots(to)) +} + // PxToDots just converts a value from pixels to dots func (uc *Context) PxToDots(val float32) float32 { return val * uc.Dots(UnitPx) diff --git a/styles/units/units_test.go b/styles/units/units_test.go index 55760b9c8c..31c68d8ec7 100644 --- a/styles/units/units_test.go +++ b/styles/units/units_test.go @@ -71,4 +71,7 @@ func TestValueConvert(t *testing.T) { if s1 != s2 { t.Errorf("strings don't match: %v != %v\n", s1, s2) } + + tolassert.Equal(t, 72, ctxt.Convert(1, UnitIn, UnitPt)) + tolassert.Equal(t, 25.4/72.0, ctxt.Convert(1, UnitPt, UnitMm)) } From 2923307c12e0200f31a60a1a199ec6357b1575ce Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 2 Oct 2025 12:32:54 +0200 Subject: [PATCH 04/99] pdf: screenshot saves PDFs -- buttons in demo looks good (minus the text) --- core/events.go | 10 ++++++++-- paint/paint_test.go | 2 +- paint/ppath/pdf/layer.go | 2 +- paint/state.go | 3 +-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/core/events.go b/core/events.go index 67fa924bec..a545ed94fb 100644 --- a/core/events.go +++ b/core/events.go @@ -1203,10 +1203,16 @@ func (em *Events) managerKeyChordEvents(e events.Event) { MessageSnackbar(sc, "Save screenshot: no render image") } sc.RenderWidget() - sv := paint.RenderToSVG(&sc.Painter) + rend := sc.Painter.RenderDone() + svr := paint.NewSVGRenderer(sc.Painter.Size) + sv := svr.Render(rend).Source() fnm := filepath.Join(TheApp.AppDataDir(), "screenshot-"+sc.Name+"-"+dstr+".svg") errors.Log(os.WriteFile(fnm, sv, 0666)) - MessageSnackbar(sc, "Saved SVG screenshot to: "+strings.ReplaceAll(fnm, " ", `\ `)+sz) + pdr := paint.NewPDFRenderer(sc.Painter.Size, &sc.Painter.Context().Style.UnitContext) + pd := pdr.Render(rend).Source() + fnm = filepath.Join(TheApp.AppDataDir(), "screenshot-"+sc.Name+"-"+dstr+".pdf") + errors.Log(os.WriteFile(fnm, pd, 0666)) + MessageSnackbar(sc, "Saved SVG, PDF screenshots to: "+strings.ReplaceAll(fnm, " ", `\ `)+sz) e.SetHandled() case keymap.ZoomIn: win.stepZoom(1) diff --git a/paint/paint_test.go b/paint/paint_test.go index 80a472d6c5..9ce16f71c7 100644 --- a/paint/paint_test.go +++ b/paint/paint_test.go @@ -37,7 +37,7 @@ func RunTest(t *testing.T, nm string, width int, height int, f func(pc *Painter) rend := pc.RenderDone() ir := NewImageRenderer(size) sv := NewSVGRenderer(size) - pd := NewPDFRenderer(size, &pc.Stack[0].Style.UnitContext) + pd := NewPDFRenderer(size, &pc.Context().Style.UnitContext) ir.Render(rend) sv.Render(rend) pd.Render(rend) diff --git a/paint/ppath/pdf/layer.go b/paint/ppath/pdf/layer.go index f65817c53f..30e0750c30 100644 --- a/paint/ppath/pdf/layer.go +++ b/paint/ppath/pdf/layer.go @@ -27,7 +27,7 @@ type pdfLayers struct { func (w *pdfWriter) layerInit() { w.layers.list = make([]pdfLayer, 0) w.layers.currentLayer = -1 - w.layers.openLayerPane = false + w.layers.openLayerPane = true } // AddLayer defines a layer that can be shown or hidden when the document is diff --git a/paint/state.go b/paint/state.go index 732a93a07d..f97d6575e9 100644 --- a/paint/state.go +++ b/paint/state.go @@ -46,8 +46,7 @@ func RenderToImage(pc *Painter) image.Image { } // RenderToSVG is a convenience function that renders the current -// accumulated painter actions to an SVG document using a -// [NewSVGRenderer].n +// accumulated painter actions to an SVG document using a [NewSVGRenderer] func RenderToSVG(pc *Painter) []byte { rd := NewSVGRenderer(pc.Size) return rd.Render(pc.RenderDone()).Source() From 054109a28f9c55a1c4612e2bb58b831d9df154f7 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 2 Oct 2025 12:45:36 +0200 Subject: [PATCH 05/99] pdf: fix image rendering --- paint/ppath/pdf/page.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/paint/ppath/pdf/page.go b/paint/ppath/pdf/page.go index cecac5c3f8..f23b51926c 100644 --- a/paint/ppath/pdf/page.go +++ b/paint/ppath/pdf/page.go @@ -463,15 +463,16 @@ func (w *pdfPageWriter) embedImage(img image.Image) pdfRef { sp := img.Bounds().Min // starting point stream := make([]byte, size.X*size.Y*3) streamMask := make([]byte, size.X*size.Y) - for y := 0; y < size.Y; y++ { - for x := 0; x < size.X; x++ { - i := (y*size.X + x) * 3 + for y := size.Y - 1; y >= 0; y-- { // invert + for x := range size.X { + pi := (size.Y-1-y)*size.X + x + i := pi * 3 R, G, B, A := img.At(sp.X+x, sp.Y+y).RGBA() if A != 0 { stream[i+0] = byte((R * 65535 / A) >> 8) stream[i+1] = byte((G * 65535 / A) >> 8) stream[i+2] = byte((B * 65535 / A) >> 8) - streamMask[y*size.X+x] = byte(A >> 8) + streamMask[pi] = byte(A >> 8) } if A>>8 != 255 { hasMask = true From 1da7202c4be5b05360aabbdc8bf65ce019d758a2 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 2 Oct 2025 16:29:27 +0200 Subject: [PATCH 06/99] pdf: text rendering working -- tricky issues with the bottom-left coord system and text transforms etc, but should be good now. --- paint/ppath/minify.go | 7 +- paint/ppath/pdf/layer.go | 11 +- paint/ppath/pdf/page.go | 78 +++++---- paint/ppath/pdf/pdf.go | 48 +----- paint/ppath/pdf/pdf_test.go | 102 +++++------ paint/ppath/pdf/text.go | 230 +++++++++++++++++++++++++ paint/ppath/pdf/writer.go | 28 ++- paint/renderers/pdfrender/pdfrender.go | 44 +---- styles/units/context.go | 10 +- 9 files changed, 364 insertions(+), 194 deletions(-) create mode 100644 paint/ppath/pdf/text.go diff --git a/paint/ppath/minify.go b/paint/ppath/minify.go index 0ef1bae847..d72b9a8c12 100644 --- a/paint/ppath/minify.go +++ b/paint/ppath/minify.go @@ -15,8 +15,11 @@ const MaxInt = int(^uint(0) >> 1) // MinInt is the minimum value of int. const MinInt = -MaxInt - 1 -// MinifyDecimal minifies a given byte slice containing a decimal and removes superfluous characters. It differs from Number in that it does not parse exponents. -// It does not parse or output exponents. prec is the number of significant digits. When prec is zero it will keep all digits. Only digits after the dot can be removed to reach the number of significant digits. Very large number may thus have more significant digits. +// MinifyDecimal minifies a given byte slice containing a decimal and removes superfluous characters. +// It differs from Number in that it does not parse exponents. +// It does not parse or output exponents. prec is the number of significant digits. +// When prec is zero it will keep all digits. Only digits after the dot can be removed +// to reach the number of significant digits. Very large number may thus have more significant digits. func MinifyDecimal(num []byte, prec int) []byte { if len(num) <= 1 { return num diff --git a/paint/ppath/pdf/layer.go b/paint/ppath/pdf/layer.go index 30e0750c30..79595c05c1 100644 --- a/paint/ppath/pdf/layer.go +++ b/paint/ppath/pdf/layer.go @@ -44,20 +44,21 @@ func (w *pdfWriter) AddLayer(name string, visible bool) (layerID int) { // BeginLayer is called to begin adding content to the specified layer. All // content added to the page between a call to BeginLayer and a call to // EndLayer is added to the layer specified by id. See AddLayer for more -// details. +// details. The graphics state is also pushed onto the stack func (w *pdfWriter) BeginLayer(id int) { w.EndLayer() if id >= 0 && id < len(w.layers.list) { - w.write("/OC /OC%d BDC", id) + w.write("/OC /OC%d BDC q", id) w.layers.currentLayer = id } } -// EndLayer is called to stop adding content to the currently active layer. See -// BeginLayer for more details. +// EndLayer is called to stop adding content to the currently active layer. +// See BeginLayer for more details. The graphics state is also popped from +// the stack. func (w *pdfWriter) EndLayer() { if w.layers.currentLayer >= 0 { - w.write("EMC") + w.write("EMC Q") w.layers.currentLayer = -1 } } diff --git a/paint/ppath/pdf/page.go b/paint/ppath/pdf/page.go index f23b51926c..fd90ba69b4 100644 --- a/paint/ppath/pdf/page.go +++ b/paint/ppath/pdf/page.go @@ -24,7 +24,7 @@ import ( "golang.org/x/text/encoding/charmap" ) -type pdfPageWriter struct { +type pdfPage struct { *bytes.Buffer pdf *pdfWriter width, height float32 @@ -34,12 +34,12 @@ type pdfPageWriter struct { graphicsStates map[float32]pdfName style styles.Paint inTextObject bool - textPosition math32.Matrix2 + textPosition math32.Vector2 textCharSpace float32 textRenderMode int } -func (w *pdfPageWriter) writePage(parent pdfRef) pdfRef { +func (w *pdfPage) writePage(parent pdfRef) pdfRef { b := w.Bytes() if 0 < len(b) && b[0] == ' ' { b = b[1:] @@ -72,7 +72,7 @@ func (w *pdfPageWriter) writePage(parent pdfRef) pdfRef { } // AddAnnotation adds an annotation. -func (w *pdfPageWriter) AddURIAction(uri string, rect math32.Box2) { +func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { annot := pdfDict{ "Type": pdfName("Annot"), "Subtype": pdfName("Link"), @@ -88,7 +88,7 @@ func (w *pdfPageWriter) AddURIAction(uri string, rect math32.Box2) { } // SetFill sets the fill style values where different from current. -func (w *pdfPageWriter) SetFill(fill *styles.Fill) { +func (w *pdfPage) SetFill(fill *styles.Fill) { if w.style.Fill.Color != fill.Color || w.style.Fill.Opacity != fill.Opacity { w.SetFillColor(fill) } @@ -96,13 +96,13 @@ func (w *pdfPageWriter) SetFill(fill *styles.Fill) { } // SetAlpha sets the transparency value. -func (w *pdfPageWriter) SetAlpha(alpha float32) { +func (w *pdfPage) SetAlpha(alpha float32) { gs := w.getOpacityGS(alpha) fmt.Fprintf(w, " /%v gs", gs) } // SetFillColor sets the filling color (image). -func (w *pdfPageWriter) SetFillColor(fill *styles.Fill) { +func (w *pdfPage) SetFillColor(fill *styles.Fill) { switch x := fill.Color.(type) { // todo: pattern, image case *gradient.Linear: @@ -122,7 +122,7 @@ func (w *pdfPageWriter) SetFillColor(fill *styles.Fill) { } // SetStroke sets the stroke style values where different from current. -func (w *pdfPageWriter) SetStroke(stroke *styles.Stroke) { +func (w *pdfPage) SetStroke(stroke *styles.Stroke) { if w.style.Stroke.Color != stroke.Color || w.style.Stroke.Opacity != stroke.Opacity { w.SetStrokeColor(stroke) } @@ -146,7 +146,7 @@ func (w *pdfPageWriter) SetStroke(stroke *styles.Stroke) { } // SetStrokeColor sets the stroking color (image). -func (w *pdfPageWriter) SetStrokeColor(stroke *styles.Stroke) { +func (w *pdfPage) SetStrokeColor(stroke *styles.Stroke) { switch x := stroke.Color.(type) { case *gradient.Linear: case *gradient.Radial: @@ -165,12 +165,12 @@ func (w *pdfPageWriter) SetStrokeColor(stroke *styles.Stroke) { } // SetStrokeWidth sets the stroke width. -func (w *pdfPageWriter) SetStrokeWidth(lineWidth float32) { +func (w *pdfPage) SetStrokeWidth(lineWidth float32) { fmt.Fprintf(w, " %v w", dec(lineWidth)) } // SetStrokeCap sets the stroke cap type. -func (w *pdfPageWriter) SetStrokeCap(capper ppath.Caps) { +func (w *pdfPage) SetStrokeCap(capper ppath.Caps) { var lineCap int switch capper { case ppath.CapButt: @@ -186,7 +186,7 @@ func (w *pdfPageWriter) SetStrokeCap(capper ppath.Caps) { } // SetStrokeJoin sets the stroke join type. -func (w *pdfPageWriter) SetStrokeJoin(joiner ppath.Joins, miterLimit float32) { +func (w *pdfPage) SetStrokeJoin(joiner ppath.Joins, miterLimit float32) { var lineJoin int switch joiner { case ppath.JoinBevel: @@ -205,7 +205,7 @@ func (w *pdfPageWriter) SetStrokeJoin(joiner ppath.Joins, miterLimit float32) { } // SetDashes sets the dash phase and array. -func (w *pdfPageWriter) SetDashes(dashPhase float32, dashArray []float32) { +func (w *pdfPage) SetDashes(dashPhase float32, dashArray []float32) { if len(dashArray)%2 == 1 { dashArray = append(dashArray, dashArray...) } @@ -235,11 +235,11 @@ func (w *pdfPageWriter) SetDashes(dashPhase float32, dashArray []float32) { } // SetFont sets the font. -func (w *pdfPageWriter) SetFont(sty *rich.Style, tsty *text.Style) error { +func (w *pdfPage) SetFont(sty *rich.Style, tsty *text.Style) error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } - size := tsty.FontHeight(sty) + size := tsty.FontHeight(sty) // * w.pdf.globalScale ref := w.pdf.getFont(sty, tsty) if _, ok := w.resources["Font"]; !ok { w.resources["Font"] = pdfDict{} @@ -258,23 +258,22 @@ func (w *pdfPageWriter) SetFont(sty *rich.Style, tsty *text.Style) error { return nil } -// SetTextPosition sets the text position. -func (w *pdfPageWriter) SetTextPosition(m math32.Matrix2) error { +// SetTextPosition sets the text offset position. +func (w *pdfPage) SetTextPosition(off math32.Vector2) error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } - if ppath.Equal(m.XX, w.textPosition.XX) && ppath.Equal(m.XY, w.textPosition.XY) && ppath.Equal(m.YX, w.textPosition.YX) && ppath.Equal(m.YY, w.textPosition.YY) { - d := w.textPosition.Inverse().MulVector2AsPoint(math32.Vec2(m.X0, m.Y0)) - fmt.Fprintf(w, " %v %v Td", dec(d.X), dec(d.Y)) - } else { - fmt.Fprintf(w, " %s Tm", mat2(m)) - } - w.textPosition = m + do := off.Sub(w.textPosition) + // and finally apply an offset from there, in reverse for Y + fmt.Fprintf(w, " %v %v Td", dec(do.X), dec(-do.Y)) + w.textPosition = off return nil } // SetTextRenderMode sets the text rendering mode. -func (w *pdfPageWriter) SetTextRenderMode(mode int) error { +// 0 = fill text, 1 = stroke text, 2 = fill, then stroke. +// higher numbers support clip path. +func (w *pdfPage) SetTextRenderMode(mode int) error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } @@ -284,7 +283,7 @@ func (w *pdfPageWriter) SetTextRenderMode(mode int) error { } // SetTextCharSpace sets the text character spacing. -func (w *pdfPageWriter) SetTextCharSpace(space float32) error { +func (w *pdfPage) SetTextCharSpace(space float32) error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } @@ -293,29 +292,36 @@ func (w *pdfPageWriter) SetTextCharSpace(space float32) error { return nil } -// StartTextObject starts a text object. -func (w *pdfPageWriter) StartTextObject() error { +// StartTextObject starts a text object, initializing the global +// CTM transform matrix as given by the arg, and setting an inverting +// text transform, so text is rendered upright. +func (w *pdfPage) StartTextObject(m math32.Matrix2) error { if w.inTextObject { return errors.Log(errors.New("pdfWriter: already in text object")) } fmt.Fprintf(w, " BT") - w.textPosition = math32.Identity2() + // set the global graphics transform to m first + fmt.Fprintf(w, " q %s cm", mat2(m)) + // then apply an inversion text matrix + tm := math32.Scale2D(1, -1) + fmt.Fprintf(w, " %s Tm", mat2(tm)) w.inTextObject = true + w.textPosition = math32.Vector2{} return nil } // EndTextObject ends a text object. -func (w *pdfPageWriter) EndTextObject() error { +func (w *pdfPage) EndTextObject() error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } - fmt.Fprintf(w, " ET") + fmt.Fprintf(w, " Q ET") w.inTextObject = false return nil } // WriteText writes text using current text style. -func (w *pdfPageWriter) WriteText(tx string) error { +func (w *pdfPage) WriteText(tx string) error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } @@ -427,7 +433,7 @@ func (w *pdfPageWriter) WriteText(tx string) error { } // DrawImage embeds and draws an image, as a lossless (PNG) -func (w *pdfPageWriter) DrawImage(img image.Image, m math32.Matrix2) { +func (w *pdfPage) DrawImage(img image.Image, m math32.Matrix2) { size := img.Bounds().Size() // add clipping path around image for smooth edges when rotating @@ -452,7 +458,7 @@ func (w *pdfPageWriter) DrawImage(img image.Image, m math32.Matrix2) { } // embedImage does a lossless image embedding. -func (w *pdfPageWriter) embedImage(img image.Image) pdfRef { +func (w *pdfPage) embedImage(img image.Image) pdfRef { if ref, ok := w.pdf.images[img]; ok { return ref } @@ -515,7 +521,7 @@ func (w *pdfPageWriter) embedImage(img image.Image) pdfRef { return ref } -func (w *pdfPageWriter) getOpacityGS(a float32) pdfName { +func (w *pdfPage) getOpacityGS(a float32) pdfName { if name, ok := w.graphicsStates[a]; ok { return name } @@ -533,7 +539,7 @@ func (w *pdfPageWriter) getOpacityGS(a float32) pdfName { } /* -func (w *pdfPageWriter) getPattern(gradient ppath.Gradient) pdfName { +func (w *pdfPage) getPattern(gradient ppath.Gradient) pdfName { // TODO: support patterns/gradients with alpha channel shading := pdfDict{ "ColorSpace": pdfName("DeviceRGB"), diff --git a/paint/ppath/pdf/pdf.go b/paint/ppath/pdf/pdf.go index 0bff794da4..3a88787c1d 100644 --- a/paint/ppath/pdf/pdf.go +++ b/paint/ppath/pdf/pdf.go @@ -15,7 +15,6 @@ import ( "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" - "cogentcore.org/core/text/shaped" ) // type Options struct { @@ -32,7 +31,7 @@ import ( // PDF is a portable document format renderer. type PDF struct { - w *pdfPageWriter + w *pdfPage width, height float32 // opts *Options } @@ -118,8 +117,8 @@ func (r *PDF) EndLayer() { r.w.pdf.EndLayer() } -// RenderPath renders a path to the canvas using a style and a transformation matrix. -func (r *PDF) RenderPath(path ppath.Path, style *styles.Paint, m math32.Matrix2) { +// Path renders a path to the canvas using a style and a transformation matrix. +func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { // PDFs don't support the arcs joiner, miter joiner (not clipped), // or miter joiner (clipped) with non-bevel fallback strokeUnsupported := false @@ -245,44 +244,7 @@ func (r *PDF) RenderPath(path ppath.Path, style *styles.Paint, m math32.Matrix2) } } -// RenderText renders a text object to the canvas using a transformation matrix, -// (the translation component specifies the starting offset) -func (r *PDF) RenderText(text *shaped.Lines, m math32.Matrix2) { - // text.WalkDecorations(func(fill canvas.Paint, p *canvas.Path) { - // style := canvas.DefaultStyle - // style.Fill = fill - // r.RenderPath(p, style, m) - // }) - - // todo: copy from other render cases - // text.WalkSpans(func(x, y float32, span canvas.TextSpan) { - // if span.IsText() { - // style := canvas.DefaultStyle - // style.Fill = span.Face.Fill - // - // r.w.StartTextObject() - // r.w.SetFill(span.Face.Fill) - // r.w.SetFont(span.Face.Font, span.Face.Size, span.Direction) - // r.w.SetTextPosition(m.Translate(x, y).Shear(span.Face.FauxItalic, 0.0)) - // - // if 0.0 < span.Face.FauxBold { - // r.w.SetTextRenderMode(2) - // r.w.SetStroke(span.Face.Fill) - // fmt.Fprintf(r.w, " %v w", dec(span.Face.FauxBold*2.0)) - // } else { - // r.w.SetTextRenderMode(0) - // } - // r.w.WriteText(text.WritingMode, span.Glyphs) - // r.w.EndTextObject() - // } else { - // for _, obj := range span.Objects { - // obj.Canvas.RenderViewTo(r, m.Mul(obj.View(x, y, span.Face))) - // } - // } - // }) -} - -// RenderImage renders an image to the canvas using a transformation matrix. -func (r *PDF) RenderImage(img image.Image, m math32.Matrix2) { +// Image renders an image to the canvas using a transformation matrix. +func (r *PDF) Image(img image.Image, m math32.Matrix2) { r.w.DrawImage(img, m) } diff --git a/paint/ppath/pdf/pdf_test.go b/paint/ppath/pdf/pdf_test.go index 35cda406d2..9cbeeadb5d 100644 --- a/paint/ppath/pdf/pdf_test.go +++ b/paint/ppath/pdf/pdf_test.go @@ -9,76 +9,64 @@ package pdf import ( "bytes" - "fmt" "os" + "path/filepath" "testing" "cogentcore.org/core/colors" "cogentcore.org/core/math32" "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/htmltext" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + _ "cogentcore.org/core/text/shaped/shapers" + "github.com/alecthomas/assert/v2" ) -func TestPDFPath(t *testing.T) { - p := ppath.MustParseSVGPath("L20 0") +// RunTest runs a test for given test case. +func RunTest(t *testing.T, nm string, width, height float32, f func(pd *PDF, sty *styles.Paint)) { + ctx := units.NewContext() + ctx.DPI = 72 var b bytes.Buffer - pd := New(&b, 50, 50) - + pd := New(&b, width, height, ctx) sty := styles.NewPaint() - sty.Defaults() - sty.Stroke.Color = colors.Uniform(colors.Blue) - sty.Fill.Color = colors.Uniform(colors.Red) - sty.Stroke.Width.Px(2) - sty.ToDots() - - pd.RenderPath(p, sty, math32.Translate2D(20, 20)) + sty.UnitContext = *ctx + f(pd, sty) pd.Close() - - fmt.Println(b.String()) os.Mkdir("testdata", 0777) - os.WriteFile("testdata/path.pdf", b.Bytes(), 0666) + os.WriteFile(filepath.Join("testdata", nm)+".pdf", b.Bytes(), 0666) +} + +func TestPath(t *testing.T) { + RunTest(t, "path", 50, 50, func(pd *PDF, sty *styles.Paint) { + p := ppath.New().Rectangle(0, 0, 30, 20) - // pdfCompress = false - // buf := &bytes.Buffer{} - // c.WritePDF(buf) - // test.T(t, buf.String(), `%PDF-1.7 - //1 0 obj - //<< /Length 14 >> stream - //0 0 m 10 0 l f - //endstream - //endobj - //2 0 obj - //<< /Type /Page /Contents 1 0 R /Group << /Type /Group /CS /DeviceRGB /I true /S /Transparency >> /MediaBox [0 0 10 10] /Parent 2 0 R /Resources << >> >> - //endobj - //3 0 obj - //<< /Type /Pages /Count 1 /Kids [2 0 R] >> - //endobj - //4 0 obj - //<< /Type /Catalog /Pages 3 0 R >> - //endobj - //xref - //0 5 - //0000000000 65535 f - //0000000009 00000 n - //0000000073 00000 n - //0000000241 00000 n - //0000000298 00000 n - //trailer - //<< /Root 4 0 R /Size 4 >> - //starxref - //347 - //%%EOF`) + sty.Stroke.Color = colors.Uniform(colors.Blue) + sty.Fill.Color = colors.Uniform(colors.Red) + sty.Stroke.Width.Px(2) + sty.ToDots() + + pd.Path(*p, sty, math32.Translate2D(10, 20)) + }) } -// func TestPDFPath(t *testing.T) { -// buf := &bytes.Buffer{} -// pdf := newPDFWriter(buf).NewPage(210.0, 297.0) -// pdf.SetAlpha(0.5) -// pdf.SetFill(canvas.Paint{Color: canvas.Red}) -// pdf.SetStroke(canvas.Paint{Color: canvas.Blue}) -// pdf.SetLineWidth(5.0) -// pdf.SetLineCap(canvas.RoundCap) -// pdf.SetLineJoin(canvas.RoundJoin) -// pdf.SetDashes(2.0, []float64{1.0, 2.0, 3.0}) -// test.String(t, pdf.String(), " 2.8346457 0 0 2.8346457 0 0 cm /A0 gs 1 0 0 rg /A1 gs 0 0 1 RG 5 w 1 J 1 j [1 2 3 1 2 3] 2 d") -// } +func TestText(t *testing.T) { + RunTest(t, "text", 300, 300, func(pd *PDF, sty *styles.Paint) { + sh := shaped.NewShaper() + rts := &rich.Settings{} + rts.Defaults() + + src := "PDF can put HTML
formatted Text where you want" + rsty := &sty.Font + tsty := &sty.Text + + tx, err := htmltext.HTMLToRich([]byte(src), rsty, nil) + // fmt.Println(tx) + assert.NoError(t, err) + lns := sh.WrapLines(tx, rsty, tsty, rts, math32.Vec2(250, 250)) + + pd.Text(sty, math32.Identity2(), math32.Vec2(10, 20), lns) + }) +} diff --git a/paint/ppath/pdf/text.go b/paint/ppath/pdf/text.go new file mode 100644 index 0000000000..c318afff11 --- /dev/null +++ b/paint/ppath/pdf/text.go @@ -0,0 +1,230 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package pdf + +import ( + "image" + + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/shaped/shapers/shapedgt" + "cogentcore.org/core/text/text" + "cogentcore.org/core/text/textpos" +) + +// Text renders text to the canvas using a transformation matrix, +// (the translation component specifies the starting offset) +func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, lns *shaped.Lines) { + r.w.StartTextObject(m) + off := pos.Add(lns.Offset) + clr := colors.Uniform(lns.Color) + runes := lns.Source.Join() + for li := range lns.Lines { + ln := &lns.Lines[li] + r.textLine(style, m, ln, lns, runes, clr, off) + } + r.w.EndTextObject() +} + +// TextLine rasterizes the given shaped.Line. +func (r *PDF) textLine(style *styles.Paint, m math32.Matrix2, ln *shaped.Line, lns *shaped.Lines, runes []rune, clr image.Image, off math32.Vector2) { + start := off.Add(ln.Offset) + off = start + for ri := range ln.Runs { + run := ln.Runs[ri].(*shapedgt.Run) + r.textRunRegions(m, run, ln, lns, off) + if run.Direction.IsVertical() { + off.Y += run.Advance() + } else { + off.X += run.Advance() + } + } + off = start + for ri := range ln.Runs { + run := ln.Runs[ri].(*shapedgt.Run) + r.textRun(style, m, run, ln, lns, runes, clr, off) + if run.Direction.IsVertical() { + off.Y += run.Advance() + } else { + off.X += run.Advance() + } + } +} + +// textRegionFill fills given regions within run with given fill color. +func (r *PDF) textRegionFill(m math32.Matrix2, run *shapedgt.Run, off math32.Vector2, fill image.Image, ranges []textpos.Range) { + if fill == nil { + return + } + for _, sel := range ranges { + rsel := sel.Intersect(run.Runes()) + if rsel.Len() == 0 { + continue + } + fi := run.FirstGlyphAt(rsel.Start) + li := run.LastGlyphAt(rsel.End - 1) + if fi >= 0 && li >= fi { + sbb := run.GlyphRegionBounds(fi, li).Canon() + r.FillBox(m, sbb.Translate(off), fill) + } + } +} + +// textRunRegions draws region fills for given run. +func (r *PDF) textRunRegions(m math32.Matrix2, run *shapedgt.Run, ln *shaped.Line, lns *shaped.Lines, off math32.Vector2) { + // dir := run.Direction + rbb := run.MaxBounds.Translate(off) + if run.Background != nil { + r.FillBox(m, rbb, run.Background) + } + r.textRegionFill(m, run, off, lns.SelectionColor, ln.Selections) + r.textRegionFill(m, run, off, lns.HighlightColor, ln.Highlights) +} + +// textRun rasterizes the given text run into the output image using the +// font face set in the shaping. +// The text will be drawn starting at the start pixel position. +func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, ln *shaped.Line, lns *shaped.Lines, runes []rune, clr image.Image, off math32.Vector2) { + // dir := run.Direction + region := run.Runes() + rbb := run.MaxBounds.Translate(off) + fill := clr + if run.FillColor != nil { + fill = run.FillColor + } + fsz := math32.FromFixed(run.Size) + lineW := max(fsz/16, 1) // 1 at 16, bigger if biggerr + if run.Math.Path != nil { + mm := m + mm.X0 += off.X + mm.Y0 += off.Y + r.Path(*run.Math.Path, style, mm) + return + } + + if run.Decoration.HasFlag(rich.Underline) || run.Decoration.HasFlag(rich.DottedUnderline) { + dash := []float32{2, 2} + if run.Decoration.HasFlag(rich.Underline) { + dash = nil + } + if run.Direction.IsVertical() { + } else { + dec := off.Y + 3 + r.strokeTextLine(m, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, dash) + } + } + if run.Decoration.HasFlag(rich.Overline) { + if run.Direction.IsVertical() { + } else { + dec := off.Y - 0.7*rbb.Size().Y + r.strokeTextLine(m, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, nil) + } + } + + r.setTextStyle(&run.Font, style, fill, run.StrokeColor, math32.FromFixed(run.Size), lns.LineHeight) + + raw := runes[region.Start:region.End] + sraw := string(raw) + r.w.SetTextPosition(off) + r.w.WriteText(sraw) + + if run.Decoration.HasFlag(rich.LineThrough) { + if run.Direction.IsVertical() { + } else { + dec := off.Y - 0.2*rbb.Size().Y + r.strokeTextLine(m, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, nil) + } + } +} + +// setTextStyle applies the given styles. +func (r *PDF) setTextStyle(fnt *text.Font, style *styles.Paint, fill, stroke image.Image, size, lineHeight float32) { + tsty := &style.Text + sty := fnt.Style(tsty) + r.w.SetFont(sty, tsty) + mode := 0 + if stroke != nil { + sc := styles.Stroke{} + sc.Defaults() + sc.Color = stroke + r.w.SetStroke(&sc) + } + if fill != nil { + fc := styles.Fill{} + fc.Defaults() + fc.Color = fill + fc.Opacity = 1 + r.w.SetFill(&fc) + if stroke != nil { + mode = 2 + } + } else { + if stroke != nil { + mode = 1 + } + } + r.w.SetTextRenderMode(mode) +} + +// strokeTextLine strokes a line for text decoration. +func (r *PDF) strokeTextLine(m math32.Matrix2, sp, ep math32.Vector2, width float32, clr image.Image, dash []float32) { + sty := styles.NewPaint() + sty.Fill.Color = nil + sty.Stroke.Width.Dots = width + sty.Stroke.Color = clr + sty.Stroke.Dashes = dash + p := ppath.New().Line(sp.X, sp.Y, ep.X, ep.Y) + r.Path(*p, sty, m) +} + +// FillBox fills a box in the given color. +func (r *PDF) FillBox(m math32.Matrix2, bb math32.Box2, clr image.Image) { + sty := styles.NewPaint() + sty.Stroke.Color = nil + sty.Fill.Color = clr + sz := bb.Size() + p := ppath.New().Rectangle(bb.Min.X, bb.Min.Y, sz.X, sz.Y) + r.Path(*p, sty, m) +} + +// text.WalkDecorations(func(fill canvas.Paint, p *canvas.Path) { +// style := canvas.DefaultStyle +// style.Fill = fill +// r.RenderPath(p, style, m) +// }) + +// todo: copy from other render cases +// text.WalkSpans(func(x, y float32, span canvas.TextSpan) { +// if span.IsText() { +// style := canvas.DefaultStyle +// style.Fill = span.Face.Fill +// +// r.w.StartTextObject() +// r.w.SetFill(span.Face.Fill) +// r.w.SetFont(span.Face.Font, span.Face.Size, span.Direction) +// r.w.SetTextPosition(m.Translate(x, y).Shear(span.Face.FauxItalic, 0.0)) +// +// if 0.0 < span.Face.FauxBold { +// r.w.SetTextRenderMode(2) +// r.w.SetStroke(span.Face.Fill) +// fmt.Fprintf(r.w, " %v w", dec(span.Face.FauxBold*2.0)) +// } else { +// r.w.SetTextRenderMode(0) +// } +// r.w.WriteText(text.WritingMode, span.Glyphs) +// r.w.EndTextObject() +// } else { +// for _, obj := range span.Objects { +// obj.Canvas.RenderViewTo(r, m.Mul(obj.View(x, y, span.Face))) +// } +// } +// }) diff --git a/paint/ppath/pdf/writer.go b/paint/ppath/pdf/writer.go index 99357e4baa..189f9bba19 100644 --- a/paint/ppath/pdf/writer.go +++ b/paint/ppath/pdf/writer.go @@ -21,6 +21,7 @@ import ( "unicode/utf16" "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" "cogentcore.org/core/text/text" @@ -34,11 +35,12 @@ type pdfWriter struct { err error unitContext units.Context + globalScale float32 // global unit conversion pos int objOffsets []int pages []pdfRef - page *pdfPageWriter + page *pdfPage fontsStd map[string]pdfRef // todo: for custom fonts: // fontSubset map[*text.Font]*ppath.FontSubsetter @@ -71,6 +73,8 @@ func newPDFWriter(writer io.Writer, un *units.Context) *pdfWriter { } w.layerInit() + w.globalScale = w.unitContext.Convert(1, units.UnitDot, units.UnitPt) + w.write("%%PDF-1.7\n") return w } @@ -454,13 +458,13 @@ func (w *pdfWriter) Close() error { } // NewPage starts a new page. -func (w *pdfWriter) NewPage(width, height float32) *pdfPageWriter { +func (w *pdfWriter) NewPage(width, height float32) *pdfPage { if w.page != nil { w.pages = append(w.pages, w.page.writePage(pdfRef(3))) } // for defaults see https://help.adobe.com/pdfl_sdk/15/PDFL_SDK_HTMLHelp/PDFL_SDK_HTMLHelp/API_References/PDFL_API_Reference/PDFEdit_Layer/General.html#_t_PDEGraphicState - w.page = &pdfPageWriter{ + w.page = &pdfPage{ Buffer: &bytes.Buffer{}, pdf: w, width: width, @@ -468,23 +472,29 @@ func (w *pdfWriter) NewPage(width, height float32) *pdfPageWriter { resources: pdfDict{}, graphicsStates: map[float32]pdfName{}, inTextObject: false, - textPosition: math32.Identity2(), + textPosition: math32.Vector2{}, textCharSpace: 0.0, textRenderMode: 0, } w.page.style.Defaults() - - sc := w.unitContext.Convert(1, units.UnitDot, units.UnitPt) - m := math32.Translate2D(0, height).Scale(sc, -sc) - fmt.Fprintf(w.page, " %s cm", mat2(m)) + w.page.SetTopTransform() return w.page } +// SetTopTransform sets the current transformation matrix so that +// the top left corner is effectively at 0,0. This is set at the +// start of each page, to align with standard rendering in cogent core. +func (w *pdfPage) SetTopTransform() { + sc := w.pdf.globalScale + m := math32.Translate2D(0, w.height).Scale(sc, -sc) + fmt.Fprintf(w, " %s cm", mat2(m)) +} + type dec float32 func (f dec) String() string { s := fmt.Sprintf("%.*f", 5, f) // precision - // s = string(minify.Decimal([]byte(s), canvas.Precision)) + s = string(ppath.MinifyDecimal([]byte(s), ppath.Precision)) if dec(math.MaxInt32) < f || f < dec(math.MinInt32) { if i := strings.IndexByte(s, '.'); i == -1 { s += ".0" diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index 19f0544567..21bae3796b 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -110,7 +110,7 @@ func (rs *Renderer) PopLayer() int { func (rs *Renderer) RenderPath(pt *render.Path) { p := pt.Path pc := &pt.Context - rs.PDF.RenderPath(p, &pc.Style, pc.Transform) + rs.PDF.Path(p, &pc.Style, pc.Transform) } func (rs *Renderer) PushContext(pt *render.ContextPush) { @@ -122,44 +122,8 @@ func (rs *Renderer) PopContext(pt *render.ContextPop) { } func (rs *Renderer) RenderText(pt *render.Text) { - // pc := &pt.Context - // cg := rs.lyStack.Peek() - // tg := fpdf.NewLayer(cg) - // props := map[string]any{} - // pt.Context.Style.GetProperties(props) - // if !pc.Transform.IsIdentity() { - // props["transform"] = pc.Transform.String() - // } - // pos := pt.Position - // tx := pt.Text.Source - // txt := tx.Join() - // for li := range pt.Text.Lines { - // ln := &pt.Text.Lines[li] - // lpos := pos.Add(ln.Offset) - // rpos := lpos - // for ri := range ln.Runs { - // run := ln.Runs[ri].(*shapedgt.Run) - // rs := run.Runes().Start - // re := run.Runes().End - // si, _, _ := tx.Index(rs) - // sty, _ := tx.Span(si) - // rtxt := txt[rs:re] - // - // st := fpdf.NewText(tg) - // st.Text = string(rtxt) - // rprops := maps.Clone(props) - // if pc.Style.UnitContext.DPI != 160 { - // sty.Size *= pc.Style.UnitContext.DPI / 160 - // } - // pt.Context.Style.Text.ToProperties(sty, rprops) - // rprops["x"] = reflectx.ToString(rpos.X) - // rprops["y"] = reflectx.ToString(rpos.Y) - // st.Pos = rpos - // st.Properties = rprops - // - // rpos.X += run.Advance() - // } - // } + pc := &pt.Context + rs.PDF.Text(&pc.Style, pc.Transform, pt.Position, pt.Text) } func (rs *Renderer) RenderImage(pr *pimage.Params) { @@ -195,6 +159,6 @@ func (rs *Renderer) RenderImage(pr *pimage.Params) { // sz := pr.Rect.Size() m := math32.Translate2D(float32(pr.Rect.Min.X), float32(pr.Rect.Min.Y)) - rs.PDF.RenderImage(usrc, m) + rs.PDF.Image(usrc, m) // simg.Pos = math32.FromPoint(pr.Rect.Min) } diff --git a/styles/units/context.go b/styles/units/context.go index 5ac79e71db..8653688e31 100644 --- a/styles/units/context.go +++ b/styles/units/context.go @@ -9,8 +9,8 @@ import ( "cogentcore.org/core/math32" ) -// Context specifies everything about the current context necessary for converting the number -// into specific display-dependent pixels +// Context specifies everything about the current context necessary +// for converting the number into specific display-dependent pixels. type Context struct { // DPI is dots-per-inch of the display @@ -47,6 +47,12 @@ type Context struct { Pah float32 } +func NewContext() *Context { + uc := &Context{} + uc.Defaults() + return uc +} + // Defaults are generic defaults func (uc *Context) Defaults() { uc.DPI = DpPerInch From dfe506d6f313af721994b91aa181d2099df2577b Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 06:44:49 +0200 Subject: [PATCH 07/99] pdf: close on text rotation but not quite.. save that for later for now.. --- paint/ppath/pdf/page.go | 4 ++-- paint/ppath/pdf/pdf_test.go | 5 ++++- paint/ppath/pdf/text.go | 5 +++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/paint/ppath/pdf/page.go b/paint/ppath/pdf/page.go index fd90ba69b4..5cbbc163ab 100644 --- a/paint/ppath/pdf/page.go +++ b/paint/ppath/pdf/page.go @@ -299,9 +299,9 @@ func (w *pdfPage) StartTextObject(m math32.Matrix2) error { if w.inTextObject { return errors.Log(errors.New("pdfWriter: already in text object")) } - fmt.Fprintf(w, " BT") // set the global graphics transform to m first fmt.Fprintf(w, " q %s cm", mat2(m)) + fmt.Fprintf(w, " BT") // then apply an inversion text matrix tm := math32.Scale2D(1, -1) fmt.Fprintf(w, " %s Tm", mat2(tm)) @@ -315,7 +315,7 @@ func (w *pdfPage) EndTextObject() error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } - fmt.Fprintf(w, " Q ET") + fmt.Fprintf(w, " ET Q") w.inTextObject = false return nil } diff --git a/paint/ppath/pdf/pdf_test.go b/paint/ppath/pdf/pdf_test.go index 9cbeeadb5d..4f82a518ab 100644 --- a/paint/ppath/pdf/pdf_test.go +++ b/paint/ppath/pdf/pdf_test.go @@ -67,6 +67,9 @@ func TestText(t *testing.T) { assert.NoError(t, err) lns := sh.WrapLines(tx, rsty, tsty, rts, math32.Vec2(250, 250)) - pd.Text(sty, math32.Identity2(), math32.Vec2(10, 20), lns) + // m := math32.Identity2() + m := math32.Rotate2D(math32.DegToRad(-15)) + + pd.Text(sty, m, math32.Vec2(20, 20), lns) }) } diff --git a/paint/ppath/pdf/text.go b/paint/ppath/pdf/text.go index c318afff11..9eb8da49f3 100644 --- a/paint/ppath/pdf/text.go +++ b/paint/ppath/pdf/text.go @@ -24,8 +24,9 @@ import ( // Text renders text to the canvas using a transformation matrix, // (the translation component specifies the starting offset) func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, lns *shaped.Lines) { - r.w.StartTextObject(m) - off := pos.Add(lns.Offset) + mt := m.Mul(math32.Translate2D(pos.X, pos.Y)) + r.w.StartTextObject(mt) + off := lns.Offset clr := colors.Uniform(lns.Color) runes := lns.Source.Join() for li := range lns.Lines { From 60b2267882353d86d8e7e84032e26c92bdc4ec5b Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 07:27:19 +0200 Subject: [PATCH 08/99] pdf: include transform in layers; eliminate empty push-pop sequences in render; fix times-roman --- paint/ppath/pdf/page.go | 5 +++++ paint/ppath/pdf/pdf.go | 8 ++++++-- paint/ppath/pdf/writer.go | 3 +++ paint/render/render.go | 10 ++++++++++ paint/renderers/pdfrender/pdfrender.go | 8 ++++---- text/paginate/README.md | 5 +++++ text/paginate/paginate.go | 5 +++++ 7 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 text/paginate/README.md create mode 100644 text/paginate/paginate.go diff --git a/paint/ppath/pdf/page.go b/paint/ppath/pdf/page.go index 5cbbc163ab..2b148412c3 100644 --- a/paint/ppath/pdf/page.go +++ b/paint/ppath/pdf/page.go @@ -71,6 +71,11 @@ func (w *pdfPage) writePage(parent pdfRef) pdfRef { return w.pdf.writeObject(page) } +// SetTransform adds a cm to set the current matrix transform (CMT). +func (w *pdfPage) SetTransform(m math32.Matrix2) { + fmt.Fprintf(w, " %s cm", mat2(m)) +} + // AddAnnotation adds an annotation. func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { annot := pdfDict{ diff --git a/paint/ppath/pdf/pdf.go b/paint/ppath/pdf/pdf.go index 3a88787c1d..2554ef429f 100644 --- a/paint/ppath/pdf/pdf.go +++ b/paint/ppath/pdf/pdf.go @@ -106,9 +106,13 @@ func (r *PDF) AddLayer(name string, visible bool) (layerID int) { // BeginLayer is called to begin adding content to the specified layer. All // content added to the page between a call to BeginLayer and a call to // EndLayer is added to the layer specified by id. See AddLayer for more -// details. -func (r *PDF) BeginLayer(id int) { +// details. Also adds a graphics stack push, and sets the given transform +// matrix, if not identity. +func (r *PDF) BeginLayer(id int, m math32.Matrix2) { r.w.pdf.BeginLayer(id) + if !m.IsIdentity() { + r.w.SetTransform(m) + } } // EndLayer is called to stop adding content to the currently active layer. See diff --git a/paint/ppath/pdf/writer.go b/paint/ppath/pdf/writer.go index 189f9bba19..d5ab0415d8 100644 --- a/paint/ppath/pdf/writer.go +++ b/paint/ppath/pdf/writer.go @@ -308,6 +308,9 @@ func standardFontName(sty *rich.Style) string { name += "-Oblique" } } + if name == "Times" { + name = "Times-Roman" // ugh + } return name } diff --git a/paint/render/render.go b/paint/render/render.go index 134ebd1d7a..1ed1405f7c 100644 --- a/paint/render/render.go +++ b/paint/render/render.go @@ -27,6 +27,16 @@ func (pr *Render) Add(item ...Item) *Render { if reflectx.IsNil(reflect.ValueOf(it)) { continue } + n := len(*pr) + if n > 0 { + // eliminate empty push-pop sequences, which occur due to invisible elements + if _, ok := it.(*ContextPop); ok { + if _, ok := (*pr)[n-1].(*ContextPush); ok { + *pr = (*pr)[:n-1] + continue + } + } + } *pr = append(*pr, it) } return pr diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index 21bae3796b..bdcbf7b591 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -71,7 +71,7 @@ func (rs *Renderer) Render(r render.Render) render.Renderer { rs.PDF = pdf.New(rs.buff, sx, sy, &rs.unitContext) rs.lyStack = nil bg := rs.PDF.AddLayer("bg", true) - rs.PDF.BeginLayer(bg) + rs.PDF.BeginLayer(bg, math32.Identity2()) rs.lyStack.Push(bg) for _, ri := range r { switch x := ri.(type) { @@ -92,11 +92,11 @@ func (rs *Renderer) Render(r render.Render) render.Renderer { return rs } -func (rs *Renderer) PushLayer() int { +func (rs *Renderer) PushLayer(m math32.Matrix2) int { cg := rs.lyStack.Peek() nm := strconv.Itoa(cg + 1) g := rs.PDF.AddLayer(nm, true) - rs.PDF.BeginLayer(g) + rs.PDF.BeginLayer(g, m) rs.lyStack.Push(g) return g } @@ -114,7 +114,7 @@ func (rs *Renderer) RenderPath(pt *render.Path) { } func (rs *Renderer) PushContext(pt *render.ContextPush) { - rs.PushLayer() // note: does not set transform.. + rs.PushLayer(pt.Context.Transform) } func (rs *Renderer) PopContext(pt *render.ContextPop) { diff --git a/text/paginate/README.md b/text/paginate/README.md new file mode 100644 index 0000000000..75f2cfc06e --- /dev/null +++ b/text/paginate/README.md @@ -0,0 +1,5 @@ +# Paginate + +The `paginate` package takes a set of input Widget trees and returns a corresponding set of page Frame widgets that fit within a specified height, with headers and page numbers specified using a template. + + diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go new file mode 100644 index 0000000000..eb4b527175 --- /dev/null +++ b/text/paginate/paginate.go @@ -0,0 +1,5 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paginate From c7cb621cc698073c6a0f42899a7dcca262c8aeaf Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 07:29:23 +0200 Subject: [PATCH 09/99] pdf: move pdf to top level in paint -- no need to be under ppath --- paint/{ppath => }/pdf/font.go | 0 paint/{ppath => }/pdf/layer.go | 0 paint/{ppath => }/pdf/page.go | 0 paint/{ppath => }/pdf/pdf.go | 0 paint/{ppath => }/pdf/pdf_test.go | 0 paint/{ppath => }/pdf/text.go | 0 paint/{ppath => }/pdf/writer.go | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename paint/{ppath => }/pdf/font.go (100%) rename paint/{ppath => }/pdf/layer.go (100%) rename paint/{ppath => }/pdf/page.go (100%) rename paint/{ppath => }/pdf/pdf.go (100%) rename paint/{ppath => }/pdf/pdf_test.go (100%) rename paint/{ppath => }/pdf/text.go (100%) rename paint/{ppath => }/pdf/writer.go (100%) diff --git a/paint/ppath/pdf/font.go b/paint/pdf/font.go similarity index 100% rename from paint/ppath/pdf/font.go rename to paint/pdf/font.go diff --git a/paint/ppath/pdf/layer.go b/paint/pdf/layer.go similarity index 100% rename from paint/ppath/pdf/layer.go rename to paint/pdf/layer.go diff --git a/paint/ppath/pdf/page.go b/paint/pdf/page.go similarity index 100% rename from paint/ppath/pdf/page.go rename to paint/pdf/page.go diff --git a/paint/ppath/pdf/pdf.go b/paint/pdf/pdf.go similarity index 100% rename from paint/ppath/pdf/pdf.go rename to paint/pdf/pdf.go diff --git a/paint/ppath/pdf/pdf_test.go b/paint/pdf/pdf_test.go similarity index 100% rename from paint/ppath/pdf/pdf_test.go rename to paint/pdf/pdf_test.go diff --git a/paint/ppath/pdf/text.go b/paint/pdf/text.go similarity index 100% rename from paint/ppath/pdf/text.go rename to paint/pdf/text.go diff --git a/paint/ppath/pdf/writer.go b/paint/pdf/writer.go similarity index 100% rename from paint/ppath/pdf/writer.go rename to paint/pdf/writer.go From 9d5658d2451e3f3e1372f1a5c97d80612bde0c06 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 07:32:22 +0200 Subject: [PATCH 10/99] pdf: fix import and yaegicore --- paint/renderers/pdfrender/pdfrender.go | 2 +- .../coresymbols/cogentcore_org-core-core.go | 37 ++++++++++++++++--- .../coresymbols/cogentcore_org-core-paint.go | 1 + .../cogentcore_org-core-styles-units.go | 1 + 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index bdcbf7b591..12bb6393d9 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -13,8 +13,8 @@ import ( "cogentcore.org/core/base/stack" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" + "cogentcore.org/core/paint/pdf" "cogentcore.org/core/paint/pimage" - "cogentcore.org/core/paint/ppath/pdf" "cogentcore.org/core/paint/render" "cogentcore.org/core/styles/units" ) diff --git a/yaegicore/coresymbols/cogentcore_org-core-core.go b/yaegicore/coresymbols/cogentcore_org-core-core.go index 91bfe591dd..9744d81763 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-core.go +++ b/yaegicore/coresymbols/cogentcore_org-core-core.go @@ -21,6 +21,13 @@ import ( func init() { Symbols["cogentcore.org/core/core/core"] = map[string]reflect.Value{ // function, constant and variable definitions + "A1": reflect.ValueOf(core.A1), + "A2": reflect.ValueOf(core.A2), + "A3": reflect.ValueOf(core.A3), + "A4": reflect.ValueOf(core.A4), + "A5": reflect.ValueOf(core.A5), + "A6": reflect.ValueOf(core.A6), + "A7": reflect.ValueOf(core.A7), "AllRenderWindows": reflect.ValueOf(&core.AllRenderWindows).Elem(), "AllSettings": reflect.ValueOf(&core.AllSettings).Elem(), "AppAbout": reflect.ValueOf(&core.AppAbout).Elem(), @@ -51,7 +58,6 @@ func init() { "CompleterStage": reflect.ValueOf(core.CompleterStage), "ConstantSpacing": reflect.ValueOf(core.ConstantSpacing), "DebugSettings": reflect.ValueOf(&core.DebugSettings).Elem(), - "DeviceSettings": reflect.ValueOf(&core.DeviceSettings).Elem(), "DialogStage": reflect.ValueOf(core.DialogStage), "ErrorDialog": reflect.ValueOf(core.ErrorDialog), "ErrorSnackbar": reflect.ValueOf(core.ErrorSnackbar), @@ -65,6 +71,8 @@ func init() { "InspectorWindow": reflect.ValueOf(core.InspectorWindow), "LayoutPassesN": reflect.ValueOf(core.LayoutPassesN), "LayoutPassesValues": reflect.ValueOf(core.LayoutPassesValues), + "Legal": reflect.ValueOf(core.Legal), + "Letter": reflect.ValueOf(core.Letter), "ListColProperty": reflect.ValueOf(constant.MakeFromLiteral("\"ls-col\"", token.STRING, 0)), "ListRowProperty": reflect.ValueOf(constant.MakeFromLiteral("\"ls-row\"", token.STRING, 0)), "LoadAllSettings": reflect.ValueOf(core.LoadAllSettings), @@ -90,6 +98,7 @@ func init() { "NewComplete": reflect.ValueOf(core.NewComplete), "NewDatePicker": reflect.ValueOf(core.NewDatePicker), "NewDurationInput": reflect.ValueOf(core.NewDurationInput), + "NewFieldValue": reflect.ValueOf(core.NewFieldValue), "NewFileButton": reflect.ValueOf(core.NewFileButton), "NewFilePicker": reflect.ValueOf(core.NewFilePicker), "NewFontButton": reflect.ValueOf(core.NewFontButton), @@ -142,6 +151,8 @@ func init() { "NewValue": reflect.ValueOf(core.NewValue), "NewWidgetBase": reflect.ValueOf(core.NewWidgetBase), "NoSentenceCaseFor": reflect.ValueOf(&core.NoSentenceCaseFor).Elem(), + "PageSizesN": reflect.ValueOf(core.PageSizesN), + "PageSizesValues": reflect.ValueOf(core.PageSizesValues), "ProfileToggle": reflect.ValueOf(core.ProfileToggle), "RecycleDialog": reflect.ValueOf(core.RecycleDialog), "RecycleMainWindow": reflect.ValueOf(core.RecycleMainWindow), @@ -181,6 +192,7 @@ func init() { "SystemSettings": reflect.ValueOf(&core.SystemSettings).Elem(), "TabTypesN": reflect.ValueOf(core.TabTypesN), "TabTypesValues": reflect.ValueOf(core.TabTypesValues), + "Tabloid": reflect.ValueOf(core.Tabloid), "TextBodyLarge": reflect.ValueOf(core.TextBodyLarge), "TextBodyMedium": reflect.ValueOf(core.TextBodyMedium), "TextBodySmall": reflect.ValueOf(core.TextBodySmall), @@ -215,6 +227,7 @@ func init() { "TileSecondLong": reflect.ValueOf(core.TileSecondLong), "TileSpan": reflect.ValueOf(core.TileSpan), "TileSplit": reflect.ValueOf(core.TileSplit), + "TimingSettings": reflect.ValueOf(&core.TimingSettings).Elem(), "ToHTML": reflect.ValueOf(core.ToHTML), "ToolbarStyles": reflect.ValueOf(core.ToolbarStyles), "TooltipStage": reflect.ValueOf(core.TooltipStage), @@ -245,9 +258,9 @@ func init() { "Complete": reflect.ValueOf((*core.Complete)(nil)), "DatePicker": reflect.ValueOf((*core.DatePicker)(nil)), "DebugSettingsData": reflect.ValueOf((*core.DebugSettingsData)(nil)), - "DeviceSettingsData": reflect.ValueOf((*core.DeviceSettingsData)(nil)), "DurationInput": reflect.ValueOf((*core.DurationInput)(nil)), "Events": reflect.ValueOf((*core.Events)(nil)), + "FieldWidgeter": reflect.ValueOf((*core.FieldWidgeter)(nil)), "FileButton": reflect.ValueOf((*core.FileButton)(nil)), "FilePaths": reflect.ValueOf((*core.FilePaths)(nil)), "FilePicker": reflect.ValueOf((*core.FilePicker)(nil)), @@ -266,6 +279,7 @@ func init() { "Icon": reflect.ValueOf((*core.Icon)(nil)), "IconButton": reflect.ValueOf((*core.IconButton)(nil)), "Image": reflect.ValueOf((*core.Image)(nil)), + "InlineLengths": reflect.ValueOf((*core.InlineLengths)(nil)), "InlineList": reflect.ValueOf((*core.InlineList)(nil)), "Inspector": reflect.ValueOf((*core.Inspector)(nil)), "KeyChordButton": reflect.ValueOf((*core.KeyChordButton)(nil)), @@ -284,6 +298,7 @@ func init() { "Meter": reflect.ValueOf((*core.Meter)(nil)), "MeterTypes": reflect.ValueOf((*core.MeterTypes)(nil)), "OnBinder": reflect.ValueOf((*core.OnBinder)(nil)), + "PageSizes": reflect.ValueOf((*core.PageSizes)(nil)), "Pages": reflect.ValueOf((*core.Pages)(nil)), "SVG": reflect.ValueOf((*core.SVG)(nil)), "Scene": reflect.ValueOf((*core.Scene)(nil)), @@ -325,6 +340,7 @@ func init() { "Themes": reflect.ValueOf((*core.Themes)(nil)), "TimeInput": reflect.ValueOf((*core.TimeInput)(nil)), "TimePicker": reflect.ValueOf((*core.TimePicker)(nil)), + "TimingSettingsData": reflect.ValueOf((*core.TimingSettingsData)(nil)), "Toolbar": reflect.ValueOf((*core.Toolbar)(nil)), "ToolbarMaker": reflect.ValueOf((*core.ToolbarMaker)(nil)), "Tree": reflect.ValueOf((*core.Tree)(nil)), @@ -341,6 +357,7 @@ func init() { // interface wrapper definitions "_ButtonEmbedder": reflect.ValueOf((*_cogentcore_org_core_core_ButtonEmbedder)(nil)), + "_FieldWidgeter": reflect.ValueOf((*_cogentcore_org_core_core_FieldWidgeter)(nil)), "_Layouter": reflect.ValueOf((*_cogentcore_org_core_core_Layouter)(nil)), "_Lister": reflect.ValueOf((*_cogentcore_org_core_core_Lister)(nil)), "_MenuSearcher": reflect.ValueOf((*_cogentcore_org_core_core_MenuSearcher)(nil)), @@ -369,6 +386,16 @@ type _cogentcore_org_core_core_ButtonEmbedder struct { func (W _cogentcore_org_core_core_ButtonEmbedder) AsButton() *core.Button { return W.WAsButton() } +// _cogentcore_org_core_core_FieldWidgeter is an interface wrapper for FieldWidgeter type +type _cogentcore_org_core_core_FieldWidgeter struct { + IValue interface{} + WFieldWidget func(field string) core.Value +} + +func (W _cogentcore_org_core_core_FieldWidgeter) FieldWidget(field string) core.Value { + return W.WFieldWidget(field) +} + // _cogentcore_org_core_core_Layouter is an interface wrapper for Layouter type type _cogentcore_org_core_core_Layouter struct { IValue interface{} @@ -795,11 +822,11 @@ func (W _cogentcore_org_core_core_ValueSetter) SetWidgetValue(value any) error { // _cogentcore_org_core_core_Valuer is an interface wrapper for Valuer type type _cogentcore_org_core_core_Valuer struct { - IValue interface{} - WValue func() core.Value + IValue interface{} + WWidget func() core.Value } -func (W _cogentcore_org_core_core_Valuer) Value() core.Value { return W.WValue() } +func (W _cogentcore_org_core_core_Valuer) Widget() core.Value { return W.WWidget() } // _cogentcore_org_core_core_Widget is an interface wrapper for Widget type type _cogentcore_org_core_core_Widget struct { diff --git a/yaegicore/coresymbols/cogentcore_org-core-paint.go b/yaegicore/coresymbols/cogentcore_org-core-paint.go index 91ababa6f1..596fcd9446 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-paint.go +++ b/yaegicore/coresymbols/cogentcore_org-core-paint.go @@ -13,6 +13,7 @@ func init() { "ClampBorderRadius": reflect.ValueOf(paint.ClampBorderRadius), "EdgeBlurFactors": reflect.ValueOf(paint.EdgeBlurFactors), "NewImageRenderer": reflect.ValueOf(&paint.NewImageRenderer).Elem(), + "NewPDFRenderer": reflect.ValueOf(&paint.NewPDFRenderer).Elem(), "NewPainter": reflect.ValueOf(paint.NewPainter), "NewSVGRenderer": reflect.ValueOf(&paint.NewSVGRenderer).Elem(), "NewSourceRenderer": reflect.ValueOf(&paint.NewSourceRenderer).Elem(), diff --git a/yaegicore/coresymbols/cogentcore_org-core-styles-units.go b/yaegicore/coresymbols/cogentcore_org-core-styles-units.go index 4af7681ff1..3c831cd662 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-styles-units.go +++ b/yaegicore/coresymbols/cogentcore_org-core-styles-units.go @@ -27,6 +27,7 @@ func init() { "Mm": reflect.ValueOf(units.Mm), "MmPerInch": reflect.ValueOf(constant.MakeFromLiteral("25.39999999999999999965305530480463858111761510372161865234375", token.FLOAT, 0)), "New": reflect.ValueOf(units.New), + "NewContext": reflect.ValueOf(units.NewContext), "Pc": reflect.ValueOf(units.Pc), "PcPerInch": reflect.ValueOf(constant.MakeFromLiteral("6", token.INT, 0)), "Ph": reflect.ValueOf(units.Ph), From 284a4e14e511f9655e1a4b12dacf682476f3796f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 11:08:02 +0200 Subject: [PATCH 11/99] pdf: pagination minimally working; pdf writer is not writing multiple pages properly. --- core/enumgen.go | 43 -------- core/image.go | 2 +- core/layout.go | 70 ++++++------- core/mainstage.go | 6 +- core/render.go | 21 ++-- core/scene.go | 16 ++- core/settings.go | 20 ---- core/splits.go | 2 +- core/stages.go | 2 +- core/text.go | 4 +- core/textfield.go | 4 +- core/toolbar.go | 2 +- core/tree.go | 12 +-- core/typegen.go | 2 +- core/widget.go | 6 +- paint/renderers/pdfrender/pdfrender.go | 11 +- text/paginate/options.go | 75 ++++++++++++++ text/paginate/pagesizes/enumgen.go | 46 +++++++++ text/paginate/pagesizes/pagesizes.go | 136 +++++++++++++++++++++++++ text/paginate/paginate.go | 86 ++++++++++++++++ text/paginate/paginate_test.go | 47 +++++++++ text/paginate/render.go | 47 +++++++++ text/paginate/typegen.go | 11 ++ 23 files changed, 535 insertions(+), 136 deletions(-) create mode 100644 text/paginate/options.go create mode 100644 text/paginate/pagesizes/enumgen.go create mode 100644 text/paginate/pagesizes/pagesizes.go create mode 100644 text/paginate/paginate_test.go create mode 100644 text/paginate/render.go create mode 100644 text/paginate/typegen.go diff --git a/core/enumgen.go b/core/enumgen.go index f1f75e6a0d..8196e543ad 100644 --- a/core/enumgen.go +++ b/core/enumgen.go @@ -337,49 +337,6 @@ func (i Themes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } // UnmarshalText implements the [encoding.TextUnmarshaler] interface. func (i *Themes) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Themes") } -var _PageSizesValues = []PageSizes{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} - -// PageSizesN is the highest valid value for type PageSizes, plus one. -const PageSizesN PageSizes = 10 - -var _PageSizesValueMap = map[string]PageSizes{`A1`: 0, `A2`: 1, `A3`: 2, `A4`: 3, `A5`: 4, `A6`: 5, `A7`: 6, `Letter`: 7, `Legal`: 8, `Tabloid`: 9} - -var _PageSizesDescMap = map[PageSizes]string{0: ``, 1: ``, 2: ``, 3: ``, 4: ``, 5: ``, 6: ``, 7: ``, 8: ``, 9: ``} - -var _PageSizesMap = map[PageSizes]string{0: `A1`, 1: `A2`, 2: `A3`, 3: `A4`, 4: `A5`, 5: `A6`, 6: `A7`, 7: `Letter`, 8: `Legal`, 9: `Tabloid`} - -// String returns the string representation of this PageSizes value. -func (i PageSizes) String() string { return enums.String(i, _PageSizesMap) } - -// SetString sets the PageSizes value from its string representation, -// and returns an error if the string is invalid. -func (i *PageSizes) SetString(s string) error { - return enums.SetString(i, s, _PageSizesValueMap, "PageSizes") -} - -// Int64 returns the PageSizes value as an int64. -func (i PageSizes) Int64() int64 { return int64(i) } - -// SetInt64 sets the PageSizes value from an int64. -func (i *PageSizes) SetInt64(in int64) { *i = PageSizes(in) } - -// Desc returns the description of the PageSizes value. -func (i PageSizes) Desc() string { return enums.Desc(i, _PageSizesDescMap) } - -// PageSizesValues returns all possible values for the type PageSizes. -func PageSizesValues() []PageSizes { return _PageSizesValues } - -// Values returns all possible values for the type PageSizes. -func (i PageSizes) Values() []enums.Enum { return enums.Values(_PageSizesValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i PageSizes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *PageSizes) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "PageSizes") -} - var _SizeClassesValues = []SizeClasses{0, 1, 2} // SizeClassesN is the highest valid value for type SizeClasses, plus one. diff --git a/core/image.go b/core/image.go index 8296e7090a..2f69c65549 100644 --- a/core/image.go +++ b/core/image.go @@ -83,7 +83,7 @@ func (im *Image) SizeUp() { obj := math32.FromPoint(im.Image.Bounds().Size()) osz := styles.ObjectSizeFromFit(im.Styles.ObjectFit, obj, sz.Actual.Content) sz.Actual.Content = osz - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) } } diff --git a/core/layout.go b/core/layout.go index cf23411500..4bce1787aa 100644 --- a/core/layout.go +++ b/core/layout.go @@ -109,8 +109,8 @@ func (ct geomCT) String() string { return fmt.Sprintf("Content: %v, \tTotal: %v", ct.Content, ct.Total) } -// geomSize has all of the relevant Layout sizes -type geomSize struct { +// GeomSize has all of the relevant Layout sizes +type GeomSize struct { // Actual is the actual size for the purposes of rendering, representing // the "external" demands of the widget for space from its parent. // This is initially the bottom-up constraint computed by SizeUp, @@ -152,41 +152,41 @@ type geomSize struct { Max math32.Vector2 } -func (ls geomSize) String() string { +func (ls GeomSize) String() string { return fmt.Sprintf("Actual: %v, \tAlloc: %v", ls.Actual, ls.Alloc) } // setInitContentMin sets initial Actual.Content size from given Styles.Min, // further subject to the current Max constraint. -func (ls *geomSize) setInitContentMin(styMin math32.Vector2) { +func (ls *GeomSize) setInitContentMin(styMin math32.Vector2) { csz := &ls.Actual.Content *csz = styMin styles.SetClampMaxVector(csz, ls.Max) } // FitSizeMax increases given size to fit given fm value, subject to Max constraints -func (ls *geomSize) FitSizeMax(to *math32.Vector2, fm math32.Vector2) { +func (ls *GeomSize) FitSizeMax(to *math32.Vector2, fm math32.Vector2) { styles.SetClampMinVector(to, fm) styles.SetClampMaxVector(to, ls.Max) } -// setTotalFromContent sets the Total size as Content plus Space -func (ls *geomSize) setTotalFromContent(ct *geomCT) { +// SetTotalFromContent sets the Total size as Content plus Space +func (ls *GeomSize) SetTotalFromContent(ct *geomCT) { ct.Total = ct.Content.Add(ls.Space) } -// setContentFromTotal sets the Content from Total size, +// SetContentFromTotal sets the Content from Total size, // subtracting Space -func (ls *geomSize) setContentFromTotal(ct *geomCT) { +func (ls *GeomSize) SetContentFromTotal(ct *geomCT) { ct.Content = ct.Total.Sub(ls.Space) } -// geomState contains the the layout geometry state for each widget. +// GeomState contains the the layout geometry state for each widget. // Set by the parent Layout during the Layout process. -type geomState struct { +type GeomState struct { // Size has sizing data for the widget: use Actual for rendering. // Alloc shows the potentially larger space top-down allocated. - Size geomSize `display:"add-fields"` + Size GeomSize `display:"add-fields"` // Pos is position within the overall Scene that we render into, // including effects of scroll offset, for both Total outer dimension @@ -216,14 +216,14 @@ type geomState struct { ContentBBox image.Rectangle `edit:"-" copier:"-" json:"-" xml:"-" set:"-"` } -func (ls *geomState) String() string { +func (ls *GeomState) String() string { return "Size: " + ls.Size.String() + "\nPos: " + ls.Pos.String() + "\tCell: " + ls.Cell.String() + "\tRelPos: " + ls.RelPos.String() + "\tScroll: " + ls.Scroll.String() } // contentRangeDim returns the Content bounding box min, max // along given dimension -func (ls *geomState) contentRangeDim(d math32.Dims) (cmin, cmax float32) { +func (ls *GeomState) contentRangeDim(d math32.Dims) (cmin, cmax float32) { cmin = float32(math32.PointDim(ls.ContentBBox.Min, d)) cmax = float32(math32.PointDim(ls.ContentBBox.Max, d)) return @@ -231,20 +231,20 @@ func (ls *geomState) contentRangeDim(d math32.Dims) (cmin, cmax float32) { // totalRect returns Pos.Total -- Size.Actual.Total // as an image.Rectangle, e.g., for bounding box -func (ls *geomState) totalRect() image.Rectangle { +func (ls *GeomState) totalRect() image.Rectangle { return math32.RectFromPosSizeMax(ls.Pos.Total, ls.Size.Actual.Total) } // contentRect returns Pos.Content, Size.Actual.Content // as an image.Rectangle, e.g., for bounding box. -func (ls *geomState) contentRect() image.Rectangle { +func (ls *GeomState) contentRect() image.Rectangle { return math32.RectFromPosSizeMax(ls.Pos.Content, ls.Size.Actual.Content) } // ScrollOffset computes the net scrolling offset as a function of // the difference between the allocated position and the actual // content position according to the clipped bounding box. -func (ls *geomState) ScrollOffset() image.Point { +func (ls *GeomState) ScrollOffset() image.Point { return ls.ContentBBox.Min.Sub(ls.Pos.Content.ToPoint()) } @@ -591,7 +591,7 @@ func (fr *Frame) laySetContentFitOverflow(nsz math32.Vector2, pass LayoutPasses) } } styles.SetClampMaxVector(asz, mx) - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) } // SizeUp (bottom-up) gathers Actual sizes from our Children & Parts, @@ -608,7 +608,7 @@ func (wb *WidgetBase) SizeUpWidget() { wb.sizeFromStyle() wb.sizeUpParts() sz := &wb.Geom.Size - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) } // spaceFromStyle sets the Space based on Style BoxSpace().Size() @@ -639,7 +639,7 @@ func (wb *WidgetBase) sizeFromStyle() { } sz.Internal.SetZero() sz.setInitContentMin(sz.Min) - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) if DebugSettings.LayoutTrace && (sz.Actual.Content.X > 0 || sz.Actual.Content.Y > 0) { fmt.Println(wb, "SizeUp from Style:", sz.Actual.Content.String()) } @@ -678,8 +678,8 @@ func (wb *WidgetBase) updateParentRelSizes() bool { if got { sz.FitSizeMax(&sz.Actual.Total, effmin) sz.FitSizeMax(&sz.Alloc.Total, effmin) - sz.setContentFromTotal(&sz.Actual) - sz.setContentFromTotal(&sz.Alloc) + sz.SetContentFromTotal(&sz.Actual) + sz.SetContentFromTotal(&sz.Alloc) } return got } @@ -1024,7 +1024,7 @@ func (wb *WidgetBase) sizeDownParts(iter int) bool { psz := &wb.Parts.Geom.Size pgrow, _ := wb.growToAllocSize(sz.Actual.Content, sz.Alloc.Content) psz.Alloc.Total = pgrow // parts = content - psz.setContentFromTotal(&psz.Alloc) + psz.SetContentFromTotal(&psz.Alloc) redo := wb.Parts.SizeDown(iter) if redo && DebugSettings.LayoutTrace { fmt.Println(wb, "Parts triggered redo") @@ -1113,7 +1113,7 @@ func (fr *Frame) sizeDownFrame(iter int) bool { fr.updateParentRelSizes() sz := &fr.Geom.Size styles.SetClampMaxVector(&sz.Alloc.Content, sz.Max) // can't be more than max.. - sz.setTotalFromContent(&sz.Alloc) + sz.SetTotalFromContent(&sz.Alloc) if DebugSettings.LayoutTrace { fmt.Println(fr, "Managing Alloc:", sz.Alloc.Content) } @@ -1204,8 +1204,8 @@ func (fr *Frame) ManageOverflow(iter int, updateSize bool) bool { } fr.This.(Layouter).LayoutSpace() // adds the scroll space if updateSize { - sz.setTotalFromContent(&sz.Actual) - sz.setContentFromTotal(&sz.Alloc) // alloc is *decreased* from any increase in space + sz.SetTotalFromContent(&sz.Actual) + sz.SetContentFromTotal(&sz.Alloc) // alloc is *decreased* from any increase in space } if change && DebugSettings.LayoutTrace { fmt.Println(fr, "ManageOverflow changed") @@ -1299,7 +1299,7 @@ func (fr *Frame) sizeDownGrowCells(iter int, extra math32.Vector2) bool { } ksz.Alloc.Total.SetDim(ma, asz) } - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) return tree.Continue }) if exn.X == 0 && exn.Y == 0 { @@ -1386,7 +1386,7 @@ func (fr *Frame) sizeDownGrowStacked(iter int, extra math32.Vector2) bool { chg = true } ksz.Alloc.Total = asz - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) } return chg } @@ -1398,7 +1398,7 @@ func (fr *Frame) sizeDownGrowStacked(iter int, extra math32.Vector2) bool { chg = true } ksz.Alloc.Total = asz - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) return tree.Continue }) return chg @@ -1432,7 +1432,7 @@ func (fr *Frame) sizeDownAllocActualCells(iter int) { asz := md.Size.Dim(ma) ksz.Alloc.Total.SetDim(ma, asz) } - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) return tree.Continue }) } @@ -1446,7 +1446,7 @@ func (fr *Frame) sizeDownAllocActualStacked(iter int) { if kwb != nil { ksz := &kwb.Geom.Size ksz.Alloc.Total = asz - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) } return } @@ -1455,7 +1455,7 @@ func (fr *Frame) sizeDownAllocActualStacked(iter int) { fr.ForWidgetChildren(func(i int, cw Widget, cwb *WidgetBase) bool { ksz := &cwb.Geom.Size ksz.Alloc.Total = asz - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) return tree.Continue }) } @@ -1473,7 +1473,7 @@ func (fr *Frame) sizeDownCustom(iter int) bool { fr.ForWidgetChildren(func(i int, cw Widget, cwb *WidgetBase) bool { ksz := &cwb.Geom.Size ksz.Alloc.Total = asz - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) return tree.Continue }) redo := fr.sizeDownChildren(iter) @@ -1505,7 +1505,7 @@ func (wb *WidgetBase) SizeFinal() { wb.growToAlloc() wb.styleSizeUpdate() // now that sizes are stable, ensure styling based on size is updated wb.sizeFinalParts() - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) } // growToAlloc grows our Actual size up to current Alloc size @@ -1524,7 +1524,7 @@ func (wb *WidgetBase) growToAlloc() bool { fmt.Println(wb, "GrowToAlloc:", sz.Alloc.Total, "from actual:", sz.Actual.Total) } sz.Actual.Total = act // already has max constraint - sz.setContentFromTotal(&sz.Actual) + sz.SetContentFromTotal(&sz.Actual) } return change } diff --git a/core/mainstage.go b/core/mainstage.go index f5f91f4ac8..7235579233 100644 --- a/core/mainstage.go +++ b/core/mainstage.go @@ -168,7 +168,7 @@ func (st *Stage) addSceneParts() { np.Y = max(np.Y, minsz) ng := sc.SceneGeom ng.Size = np - sc.resize(ng) + sc.Resize(ng) }) } } @@ -266,7 +266,7 @@ func (st *Stage) runWindow() *Stage { } if st.NewWindow || currentRenderWindow == nil { - sc.resize(math32.Geom2DInt{st.renderContext.geom.Pos, sz}) + sc.Resize(math32.Geom2DInt{st.renderContext.geom.Pos, sz}) win := st.newRenderWindow() mainRenderWindows.add(win) setCurrentRenderWindow(win) @@ -352,7 +352,7 @@ func (st *Stage) runDialog() *Stage { if st.NewWindow { st.Mains = nil - sc.resize(math32.Geom2DInt{st.renderContext.geom.Pos, sz}) + sc.Resize(math32.Geom2DInt{st.renderContext.geom.Pos, sz}) st.Type = WindowStage // critical: now is its own window! sc.SceneGeom.Pos = image.Point{} // ignore pos win := st.newRenderWindow() diff --git a/core/render.go b/core/render.go index d9692dcb90..2137c8d38a 100644 --- a/core/render.go +++ b/core/render.go @@ -122,16 +122,15 @@ func (wb *WidgetBase) NeedsRebuild() bool { return rc.rebuild } -// layoutScene does a layout of the scene: Size, Position -func (sc *Scene) layoutScene() { +// LayoutScene does a layout of the scene: Size, Position +func (sc *Scene) LayoutScene() { if DebugSettings.LayoutTrace { fmt.Println("\n############################\nLayoutScene SizeUp start:", sc) } sc.SizeUp() sz := &sc.Geom.Size sz.Alloc.Total.SetPoint(sc.SceneGeom.Size) - sz.setContentFromTotal(&sz.Alloc) - // sz.Actual = sz.Alloc // todo: is this needed?? + sz.SetContentFromTotal(&sz.Alloc) if DebugSettings.LayoutTrace { fmt.Println("\n############################\nSizeDown start:", sc) } @@ -160,10 +159,10 @@ func (sc *Scene) layoutScene() { sc.ApplyScenePos() } -// layoutRenderScene does a layout and render of the tree: +// LayoutRenderScene does a layout and render of the tree: // GetSize, DoLayout, Render. Needed after Config. -func (sc *Scene) layoutRenderScene() { - sc.layoutScene() +func (sc *Scene) LayoutRenderScene() { + sc.LayoutScene() sc.RenderWidget() } @@ -229,14 +228,14 @@ func (sc *Scene) doUpdate() bool { case sc.lastRender.needsRestyle(rc): // pr := profile.Start("restyle") sc.applyStyleScene() - sc.layoutRenderScene() + sc.LayoutRenderScene() sc.setFlag(false, sceneNeedsLayout, sceneNeedsRender) sc.setFlag(true, sceneImageUpdated) sc.lastRender.saveRender(rc) // pr.End() case sc.hasFlag(sceneNeedsLayout): // pr := profile.Start("layout") - sc.layoutRenderScene() + sc.LayoutRenderScene() sc.setFlag(false, sceneNeedsLayout, sceneNeedsRender) sc.setFlag(true, sceneImageUpdated) // pr.End() @@ -290,7 +289,7 @@ func (sc *Scene) doRebuild() { sc.Stage.Sprites.reset() sc.updateScene() sc.applyStyleScene() - sc.layoutRenderScene() + sc.LayoutRenderScene() } // contentSize computes the size of the scene based on current content. @@ -303,7 +302,7 @@ func (sc *Scene) contentSize(initSz image.Point) image.Point { sc.setFlag(true, sceneContentSizing) sc.updateScene() sc.applyStyleScene() - sc.layoutScene() + sc.LayoutScene() sz := &sc.Geom.Size psz := sz.Actual.Total sc.setFlag(false, sceneContentSizing) diff --git a/core/scene.go b/core/scene.go index 5a5fb36a87..bd12cf8394 100644 --- a/core/scene.go +++ b/core/scene.go @@ -100,6 +100,9 @@ type Scene struct { //core:no-new // instead of rendering into the Scene Painter. directRenders []Widget + // this is our own text shaper in case we don't have a render context + textShaper shaped.Shaper + // flags are atomic bit flags for [Scene] state. flags sceneFlags } @@ -248,6 +251,11 @@ func (sc *Scene) renderContext() *renderContext { return sm.renderContext } +// MakeTextShaper makes our own text shaper for offline use +func (sc *Scene) MakeTextShaper() { + sc.textShaper = shaped.NewShaper() +} + // TextShaper returns the current [shaped.TextShaper], for text shaping. // may be nil if not yet initialized. func (sc *Scene) TextShaper() shaped.Shaper { @@ -255,7 +263,7 @@ func (sc *Scene) TextShaper() shaped.Shaper { if rc != nil { return rc.textShaper } - return nil + return sc.textShaper } // RenderWindow returns the current render window for this scene. @@ -278,12 +286,12 @@ func (sc *Scene) RenderWindow() *renderWindow { func (sc *Scene) fitInWindow(winGeom math32.Geom2DInt) bool { geom := sc.SceneGeom geom = geom.FitInWindow(winGeom) - return sc.resize(geom) + return sc.Resize(geom) } -// resize resizes the scene if needed, creating a new image; updates Geom. +// Resize resizes the scene if needed, creating a new image; updates Geom. // returns false if the scene is already the correct size. -func (sc *Scene) resize(geom math32.Geom2DInt) bool { +func (sc *Scene) Resize(geom math32.Geom2DInt) bool { if geom.Size.X <= 0 || geom.Size.Y <= 0 { return false } diff --git a/core/settings.go b/core/settings.go index 2a254facf6..1247bfea08 100644 --- a/core/settings.go +++ b/core/settings.go @@ -551,9 +551,6 @@ type SystemSettingsData struct { //types:add // whether to use a 24-hour clock (instead of AM and PM) Clock24 bool `label:"24-hour clock"` - // default page size for PDF generation, typically either Letter or A4. - PageSize PageSizes - // user info, which is partially filled-out automatically if empty // when settings are first created. User User @@ -573,7 +570,6 @@ func (ss *SystemSettingsData) Defaults() { ss.KeyMap = keymap.DefaultMap ss.KeyMaps.Value = keymap.AvailableMaps ss.FavPaths.setToDefaults() - ss.PageSize = Letter ss.updateUser() } @@ -628,22 +624,6 @@ type User struct { //types:add Email string } -// PageSizes are the different possible page sizes that a user can select in their settings. -type PageSizes int32 //enums:enum - -const ( - A1 PageSizes = iota - A2 - A3 - A4 - A5 - A6 - A7 - Letter - Legal - Tabloid -) - //////// FavoritePaths // favoritePathItem represents one item in a favorite path list, for display of diff --git a/core/splits.go b/core/splits.go index 74a4c71862..2ac5292f36 100644 --- a/core/splits.go +++ b/core/splits.go @@ -730,7 +730,7 @@ func (sl *Splits) SizeDownSetAllocs(iter int) { ksz := &cwb.Geom.Size ksz.Alloc.Total.SetDim(dim, szm) ksz.Alloc.Total.SetDim(odim, szc) - ksz.setContentFromTotal(&ksz.Alloc) + ksz.SetContentFromTotal(&ksz.Alloc) } ci := 0 diff --git a/core/stages.go b/core/stages.go index 1dcb7708b7..e4e39c20e3 100644 --- a/core/stages.go +++ b/core/stages.go @@ -216,7 +216,7 @@ func (sm *stages) resize(rg math32.Geom2DInt) bool { for _, kv := range sm.stack.Order { st := kv.Value if st.FullWindow { - did := st.Scene.resize(rg) + did := st.Scene.Resize(rg) if did { st.Sprites.reset() resized = true diff --git a/core/text.go b/core/text.go index 2e35a39997..d95ebe2a59 100644 --- a/core/text.go +++ b/core/text.go @@ -463,7 +463,7 @@ func (tx *Text) SizeUp() { } rsz := tx.paintText.Bounds.Size().Ceil() sz.FitSizeMax(&sz.Actual.Content, rsz) - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) if DebugSettings.LayoutTrace { fmt.Println(tx, "Text SizeUp:", rsz, "Actual:", sz.Actual.Content) } @@ -480,7 +480,7 @@ func (tx *Text) SizeDown(iter int) bool { // start over so we don't reflect hysteresis of prior guess sz.setInitContentMin(tx.Styles.Min.Dots().Ceil()) sz.FitSizeMax(&sz.Actual.Content, rsz) - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) chg := prevContent != sz.Actual.Content if chg { if DebugSettings.LayoutTrace { diff --git a/core/textfield.go b/core/textfield.go index b0c0e20cc8..f5d5f1ee79 100644 --- a/core/textfield.go +++ b/core/textfield.go @@ -1651,7 +1651,7 @@ func (tf *TextField) SizeUp() { rsz := tf.configTextSize(availSz) rsz.SetAdd(icsz) sz.FitSizeMax(&sz.Actual.Content, rsz) - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) tf.lineHeight = tf.Styles.LineHeightDots() if DebugSettings.LayoutTrace { fmt.Println(tf, "TextField SizeUp:", rsz, "Actual:", sz.Actual.Content) @@ -1681,7 +1681,7 @@ func (tf *TextField) SizeDown(iter int) bool { rsz.Y = max(pgrow.Y, rsz.Y) } sz.FitSizeMax(&sz.Actual.Content, rsz) - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) sz.Alloc = sz.Actual // this is important for constraining our children layout: redo := tf.Frame.SizeDown(iter) return chg || redo diff --git a/core/toolbar.go b/core/toolbar.go index 1bf4effbac..e2719bfbf8 100644 --- a/core/toolbar.go +++ b/core/toolbar.go @@ -153,7 +153,7 @@ func (tb *Toolbar) moveToOverflow() { avsz := avail - ovsz sz := &tb.Geom.Size sz.Alloc.Total.SetDim(ma, avail) - sz.setContentFromTotal(&sz.Alloc) + sz.SetContentFromTotal(&sz.Alloc) n := len(tb.Children) pn := len(tb.allItemsPlan.Children) ovidx := n - 1 diff --git a/core/tree.go b/core/tree.go index 9a6ca2bf4f..adac720e4f 100644 --- a/core/tree.go +++ b/core/tree.go @@ -592,7 +592,7 @@ func (tr *Tree) SizeUp() { } sz := &tr.Geom.Size sz.Actual.Content = math32.Vec2(w, h) - sz.setTotalFromContent(&sz.Actual) + sz.SetTotalFromContent(&sz.Actual) sz.Alloc = sz.Actual // need allocation to match! tr.widgetSize.X = w // stretch } @@ -618,7 +618,7 @@ func (tr *Tree) Position() { sz.Alloc = sz.Actual psz := &tr.Parts.Geom.Size psz.Alloc.Total = tr.widgetSize - psz.setContentFromTotal(&psz.Alloc) + psz.SetContentFromTotal(&psz.Alloc) tr.WidgetBase.Position() // just does our parts @@ -637,7 +637,7 @@ func (tr *Tree) Position() { func (tr *Tree) ApplyScenePos() { sz := &tr.Geom.Size if sz.Actual.Total == tr.widgetSize { - sz.setTotalFromContent(&sz.Actual) // restore after scrolling + sz.SetTotalFromContent(&sz.Actual) // restore after scrolling } tr.WidgetBase.ApplyScenePos() tr.applyScenePosChildren() @@ -1467,7 +1467,7 @@ func (tr *Tree) PasteAssign(md mimedata.Mimes) { return } tr.CopyFrom(sl[0]) // nodes with data copy here - tr.setScene(tr.Scene) // ensure children have scene + tr.SetScene(tr.Scene) // ensure children have scene tr.Update() // could have children tr.Open() tr.sendChangeEvent() @@ -1525,7 +1525,7 @@ func (tr *Tree) pasteAt(md mimedata.Mimes, mod events.DropMods, rel int, actNm s parent.InsertChild(ns, myidx+i) nwb := AsWidget(ns) AsTree(ns).Root = tr.Root - nwb.setScene(tr.Scene) + nwb.SetScene(tr.Scene) nwb.Update() // incl children npath := nst.PathFrom(tr.Root) if mod == events.DropMove && npath == orgpath { // we will be nuked immediately after drag @@ -1555,7 +1555,7 @@ func (tr *Tree) PasteChildren(md mimedata.Mimes, mod events.DropMods) { tree.SetUniqueNameIfDuplicate(tr.This, ns) tr.AddChild(ns) AsTree(ns).Root = tr.Root - AsWidget(ns).setScene(tr.Scene) + AsWidget(ns).SetScene(tr.Scene) } tr.Update() tr.Open() diff --git a/core/typegen.go b/core/typegen.go index 793227d308..cc1e81145f 100644 --- a/core/typegen.go +++ b/core/typegen.go @@ -626,7 +626,7 @@ var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.TimingSettings var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.ScreenSettings", IDName: "screen-settings", Doc: "ScreenSettings are per-screen settings that override the global settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Zoom", Doc: "overall zoom factor as a percentage of the default zoom"}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.SystemSettingsData", IDName: "system-settings-data", Doc: "SystemSettingsData is the data type of the global Cogent Core settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Doc: "Apply detailed settings to all the relevant settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "KeyMap", Doc: "The keyboard shortcut map to use"}, {Name: "KeyMaps", Doc: "The keyboard shortcut maps available as options for Key map.\nIf you do not want to have custom key maps, you should leave\nthis unset so that you always have the latest standard key maps."}, {Name: "Editor", Doc: "text editor settings"}, {Name: "Clock24", Doc: "whether to use a 24-hour clock (instead of AM and PM)"}, {Name: "PageSize", Doc: "default page size for PDF generation, typically either Letter or A4."}, {Name: "User", Doc: "user info, which is partially filled-out automatically if empty\nwhen settings are first created."}, {Name: "BigFileSize", Doc: "the limit of file size, above which user will be prompted before\nopening / copying, etc."}, {Name: "SavedPathsMax", Doc: "maximum number of saved paths to save in FilePicker"}, {Name: "FavPaths", Doc: "favorite paths, shown in FilePickerer and also editable there"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.SystemSettingsData", IDName: "system-settings-data", Doc: "SystemSettingsData is the data type of the global Cogent Core settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Doc: "Apply detailed settings to all the relevant settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "KeyMap", Doc: "The keyboard shortcut map to use"}, {Name: "KeyMaps", Doc: "The keyboard shortcut maps available as options for Key map.\nIf you do not want to have custom key maps, you should leave\nthis unset so that you always have the latest standard key maps."}, {Name: "Editor", Doc: "text editor settings"}, {Name: "Clock24", Doc: "whether to use a 24-hour clock (instead of AM and PM)"}, {Name: "User", Doc: "user info, which is partially filled-out automatically if empty\nwhen settings are first created."}, {Name: "BigFileSize", Doc: "the limit of file size, above which user will be prompted before\nopening / copying, etc."}, {Name: "SavedPathsMax", Doc: "maximum number of saved paths to save in FilePicker"}, {Name: "FavPaths", Doc: "favorite paths, shown in FilePickerer and also editable there"}}}) var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.User", IDName: "user", Doc: "User basic user information that might be needed for different apps", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "User"}}, Fields: []types.Field{{Name: "Email", Doc: "default email address -- e.g., for recording changes in a version control system"}}}) diff --git a/core/widget.go b/core/widget.go index 3fdc0700be..bc8169bf71 100644 --- a/core/widget.go +++ b/core/widget.go @@ -158,7 +158,7 @@ type WidgetBase struct { Parts *Frame `copier:"-" json:"-" xml:"-" set:"-"` // Geom has the full layout geometry for size and position of this widget. - Geom geomState `edit:"-" copier:"-" json:"-" xml:"-" set:"-"` + Geom GeomState `edit:"-" copier:"-" json:"-" xml:"-" set:"-"` // OverrideStyle, if true, indicates override the computed styles of the widget // and allow directly editing [WidgetBase.Styles]. It is typically only set in @@ -311,10 +311,10 @@ func (wb *WidgetBase) OnAdd() { } } -// setScene sets the Scene pointer for this widget and all of its children. +// SetScene sets the Scene pointer for this widget and all of its children. // This can be necessary when creating widgets outside the usual New* paradigm, // e.g., when reading from a JSON file. -func (wb *WidgetBase) setScene(sc *Scene) { +func (wb *WidgetBase) SetScene(sc *Scene) { wb.WidgetWalkDown(func(cw Widget, cwb *WidgetBase) bool { cwb.Scene = sc return tree.Continue diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index 12bb6393d9..765b618cf8 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -7,6 +7,7 @@ package pdfrender import ( "bytes" "image" + "io" "strconv" "cogentcore.org/core/base/iox/imagex" @@ -65,10 +66,17 @@ func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { // Render is the main rendering function. func (rs *Renderer) Render(r render.Render) render.Renderer { rs.buff = &bytes.Buffer{} + rs.RenderPage(rs.buff, r) + rs.PDF.Close() + return rs +} + +// RenderPage is the main rendering function, rendering to a writer +func (rs *Renderer) RenderPage(w io.Writer, r render.Render) render.Renderer { // pdf is in points sx := rs.unitContext.Convert(float32(rs.size.X), rs.sizeUnits, units.UnitPt) sy := rs.unitContext.Convert(float32(rs.size.Y), rs.sizeUnits, units.UnitPt) - rs.PDF = pdf.New(rs.buff, sx, sy, &rs.unitContext) + rs.PDF = pdf.New(w, sx, sy, &rs.unitContext) rs.lyStack = nil bg := rs.PDF.AddLayer("bg", true) rs.PDF.BeginLayer(bg, math32.Identity2()) @@ -88,7 +96,6 @@ func (rs *Renderer) Render(r render.Render) render.Renderer { } } rs.PDF.EndLayer() - rs.PDF.Close() return rs } diff --git a/text/paginate/options.go b/text/paginate/options.go new file mode 100644 index 0000000000..2211f5da7b --- /dev/null +++ b/text/paginate/options.go @@ -0,0 +1,75 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:generate core generate -add-types + +package paginate + +import ( + "cogentcore.org/core/math32" + "cogentcore.org/core/styles/sides" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/paginate/pagesizes" +) + +// Options has the parameters for pagination. +type Options struct { + // PageSize specifies a standard page size, or Custom. + PageSize pagesizes.Sizes + + // Units are the units in which size is specified. + // Will automatically be set if PageSize != Custom. + Units units.Units + + // Size is the size in given units. + // Will automatically be set if PageSize != Custom. + Size math32.Vector2 + + // Margins specify the page margins in the size units. + Margins sides.Floats `display:"inline"` + + // Header is the header template string, with # + // replaced with the page number + // adds a stretch element that can be used to accomplish + // justification: at start = right justify, at start and end = center + Header string + + // Footer is the footer template string, with # + // replaced with the page number. + // adds a stretch element that can be used to accomplish + // justification: at start = right justify, at start and end = center + Footer string + + sizeDots math32.Vector2 // total size in dots + bodyDots math32.Vector2 // body (content) size in dots + margDots sides.Floats // margin sizes in dots +} + +func NewOptions() Options { + o := Options{} + o.Defaults() + return o +} + +func (o *Options) Defaults() { + // todo: make this contingent on localization somehow! + o.PageSize = pagesizes.A4 + o.Margins.Set(25) // basically one inch + o.Footer = "#" + o.Update() +} + +func (o *Options) Update() { + if o.PageSize != pagesizes.Custom { + o.Units, o.Size = o.PageSize.Size() + } +} + +func (o *Options) ToDots(un *units.Context) { + sc := un.ToDots(1, o.Units) + o.sizeDots = o.Size.MulScalar(sc) + o.margDots = o.Margins.MulScalar(sc) + o.bodyDots.X = o.sizeDots.X - (o.margDots.Left + o.margDots.Right) + o.bodyDots.Y = o.sizeDots.Y - (o.margDots.Top + o.margDots.Bottom) +} diff --git a/text/paginate/pagesizes/enumgen.go b/text/paginate/pagesizes/enumgen.go new file mode 100644 index 0000000000..0d3a4b3edb --- /dev/null +++ b/text/paginate/pagesizes/enumgen.go @@ -0,0 +1,46 @@ +// Code generated by "core generate"; DO NOT EDIT. + +package pagesizes + +import ( + "cogentcore.org/core/enums" +) + +var _SizesValues = []Sizes{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21} + +// SizesN is the highest valid value for type Sizes, plus one. +const SizesN Sizes = 22 + +var _SizesValueMap = map[string]Sizes{`Custom`: 0, `Img1280x720`: 1, `Img1920x1080`: 2, `Img3840x2160`: 3, `Img7680x4320`: 4, `Img1024x768`: 5, `Img720x480`: 6, `Img640x480`: 7, `Img320x240`: 8, `A4`: 9, `USLetter`: 10, `USLegal`: 11, `A0`: 12, `A1`: 13, `A2`: 14, `A3`: 15, `A5`: 16, `A6`: 17, `A7`: 18, `A8`: 19, `A9`: 20, `A10`: 21} + +var _SizesDescMap = map[Sizes]string{0: `Custom = nonstandard`, 1: `Image 1280x720 Px = 720p`, 2: `Image 1920x1080 Px = 1080p HD`, 3: `Image 3840x2160 Px = 4K`, 4: `Image 7680x4320 Px = 8K`, 5: `Image 1024x768 Px = XGA`, 6: `Image 720x480 Px = DVD`, 7: `Image 640x480 Px = VGA`, 8: `Image 320x240 Px = old CRT`, 9: `A4 = 210 x 297 mm`, 10: `USLetter = 8.5 x 11 in = 612 x 792 pt`, 11: `USLegal = 8.5 x 14 in = 612 x 1008 pt`, 12: `A0 = 841 x 1189 mm`, 13: `A1 = 594 x 841 mm`, 14: `A2 = 420 x 594 mm`, 15: `A3 = 297 x 420 mm`, 16: `A5 = 148 x 210 mm`, 17: `A6 = 105 x 148 mm`, 18: `A7 = 74 x 105`, 19: `A8 = 52 x 74 mm`, 20: `A9 = 37 x 52`, 21: `A10 = 26 x 37`} + +var _SizesMap = map[Sizes]string{0: `Custom`, 1: `Img1280x720`, 2: `Img1920x1080`, 3: `Img3840x2160`, 4: `Img7680x4320`, 5: `Img1024x768`, 6: `Img720x480`, 7: `Img640x480`, 8: `Img320x240`, 9: `A4`, 10: `USLetter`, 11: `USLegal`, 12: `A0`, 13: `A1`, 14: `A2`, 15: `A3`, 16: `A5`, 17: `A6`, 18: `A7`, 19: `A8`, 20: `A9`, 21: `A10`} + +// String returns the string representation of this Sizes value. +func (i Sizes) String() string { return enums.String(i, _SizesMap) } + +// SetString sets the Sizes value from its string representation, +// and returns an error if the string is invalid. +func (i *Sizes) SetString(s string) error { return enums.SetString(i, s, _SizesValueMap, "Sizes") } + +// Int64 returns the Sizes value as an int64. +func (i Sizes) Int64() int64 { return int64(i) } + +// SetInt64 sets the Sizes value from an int64. +func (i *Sizes) SetInt64(in int64) { *i = Sizes(in) } + +// Desc returns the description of the Sizes value. +func (i Sizes) Desc() string { return enums.Desc(i, _SizesDescMap) } + +// SizesValues returns all possible values for the type Sizes. +func SizesValues() []Sizes { return _SizesValues } + +// Values returns all possible values for the type Sizes. +func (i Sizes) Values() []enums.Enum { return enums.Values(_SizesValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Sizes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Sizes) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Sizes") } diff --git a/text/paginate/pagesizes/pagesizes.go b/text/paginate/pagesizes/pagesizes.go new file mode 100644 index 0000000000..d8a3ab77c1 --- /dev/null +++ b/text/paginate/pagesizes/pagesizes.go @@ -0,0 +1,136 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:generate core generate + +// package pagesizes provides an enum of standard page sizes +// including image (e.g., 1080p, 4K, etc) and printed page sizes +// (e.g., A4, USLetter). +package pagesizes + +import ( + "cogentcore.org/core/math32" + "cogentcore.org/core/styles/units" +) + +// Sizes are standard physical drawing sizes +type Sizes int32 //enums:enum + +const ( + // Custom = nonstandard + Custom Sizes = iota + + // Image 1280x720 Px = 720p + Img1280x720 + + // Image 1920x1080 Px = 1080p HD + Img1920x1080 + + // Image 3840x2160 Px = 4K + Img3840x2160 + + // Image 7680x4320 Px = 8K + Img7680x4320 + + // Image 1024x768 Px = XGA + Img1024x768 + + // Image 720x480 Px = DVD + Img720x480 + + // Image 640x480 Px = VGA + Img640x480 + + // Image 320x240 Px = old CRT + Img320x240 + + // A4 = 210 x 297 mm + A4 + + // USLetter = 8.5 x 11 in = 612 x 792 pt + USLetter + + // USLegal = 8.5 x 14 in = 612 x 1008 pt + USLegal + + // A0 = 841 x 1189 mm + A0 + + // A1 = 594 x 841 mm + A1 + + // A2 = 420 x 594 mm + A2 + + // A3 = 297 x 420 mm + A3 + + // A5 = 148 x 210 mm + A5 + + // A6 = 105 x 148 mm + A6 + + // A7 = 74 x 105 + A7 + + // A8 = 52 x 74 mm + A8 + + // A9 = 37 x 52 + A9 + + // A10 = 26 x 37 + A10 +) + +// Size returns the corresponding size values and units. +func (s Sizes) Size() (un units.Units, size math32.Vector2) { + v := sizesMap[s] + return v.un, math32.Vec2(v.x, v.y) +} + +// Match returns a matching standard size for given units and dimension. +func Match(un units.Units, wd, ht float32) Sizes { + trgl := values{un: un, x: wd, y: ht} + trgp := values{un: un, x: ht, y: wd} + for k, v := range sizesMap { + if *v == trgl || *v == trgp { + return k + } + } + return Custom +} + +// values are values for standard sizes +type values struct { + un units.Units + x float32 + y float32 +} + +// sizesMap is the map of size values for each standard size +var sizesMap = map[Sizes]*values{ + Img1280x720: {units.UnitPx, 1280, 720}, + Img1920x1080: {units.UnitPx, 1920, 1080}, + Img3840x2160: {units.UnitPx, 3840, 2160}, + Img7680x4320: {units.UnitPx, 7680, 4320}, + Img1024x768: {units.UnitPx, 1024, 768}, + Img720x480: {units.UnitPx, 720, 480}, + Img640x480: {units.UnitPx, 640, 480}, + Img320x240: {units.UnitPx, 320, 240}, + A4: {units.UnitMm, 210, 297}, + USLetter: {units.UnitPt, 612, 792}, + USLegal: {units.UnitPt, 612, 1008}, + A0: {units.UnitMm, 841, 1189}, + A1: {units.UnitMm, 594, 841}, + A2: {units.UnitMm, 420, 594}, + A3: {units.UnitMm, 297, 420}, + A5: {units.UnitMm, 148, 210}, + A6: {units.UnitMm, 105, 148}, + A7: {units.UnitMm, 74, 105}, + A8: {units.UnitMm, 52, 74}, + A9: {units.UnitMm, 37, 52}, + A10: {units.UnitMm, 26, 37}, +} diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index eb4b527175..affc2a4d41 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -3,3 +3,89 @@ // license that can be found in the LICENSE file. package paginate + +import ( + "cogentcore.org/core/core" + "cogentcore.org/core/styles" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/tree" +) + +// Paginate organizes the given input widget content into frames +// that each fit within the page size specified in the options. +func Paginate(opts Options, ins ...core.Widget) []*core.Frame { + if len(ins) == 0 { + return nil + } + p := pager{opts: &opts, ins: ins} + p.paginate() + return p.outs +} + +// pager implements the pagination. +type pager struct { + opts *Options + ins []core.Widget + outs []*core.Frame + + ctx units.Context +} + +func (p *pager) paginate() { + p.opts.Update() + p.ctx = p.ins[0].(core.Widget).AsWidget().Styles.UnitContext + p.opts.ToDots(&p.ctx) + widg := core.AsWidget + + ii := 0 + ci := 0 + cIn := widg(p.ins[ii]) + cw := widg(cIn.Child(ci)) + atEnd := false + for { + // find height + ht := float32(0) + var ws []core.Widget + for { + if cw == nil { + atEnd = true + break + } + gp := cIn.Styles.Gap.Dots().Floor() + ht += cw.Geom.Size.Actual.Total.Y + gp.Y + if ht >= p.opts.bodyDots.Y { + break + } + ws = append(ws, cw.This.(core.Widget)) + ci++ + if ci >= cIn.NumChildren() { + ci = 0 + ii++ + if ii >= len(p.ins) { + atEnd = true + break + } + cIn = widg(p.ins[ii]) + } + cw = widg(cIn.Child(ci)) + } + // todo: rearrange elements to put text at bottom and non-text at top + // now transfer over to frame + cOut := core.NewFrame() + cOut.Styler(func(s *styles.Style) { + s.Direction = styles.Column + s.Min.X.Dot(p.opts.sizeDots.X) + s.Min.Y.Dot(p.opts.sizeDots.Y) + s.Max.X.Dot(p.opts.sizeDots.X) + s.Max.Y.Dot(p.opts.sizeDots.Y) + }) + for _, w := range ws { + tree.MoveToParent(w, cOut) + } + // fmt.Println("ht:", ht, "n:", len(ws)) + p.outs = append(p.outs, cOut) + if atEnd { + break + } + } +} diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go new file mode 100644 index 0000000000..d988a4a6ac --- /dev/null +++ b/text/paginate/paginate_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paginate + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "cogentcore.org/core/core" + "cogentcore.org/core/events" + "cogentcore.org/core/styles" +) + +// RunTest runs a test for given test case. +func RunTest(t *testing.T, nm string, f func() *core.Body) { + b := f() + // b.AssertRender(t, "text-only") + showed := make(chan struct{}) + b.OnFinal(events.Show, func(e events.Event) { + showed <- struct{}{} + }) + b.RunWindow() + <-showed + + buff := bytes.Buffer{} + PDF(&buff, NewOptions(), b) + os.Mkdir("testdata", 0777) + os.WriteFile(filepath.Join("testdata", nm)+".pdf", buff.Bytes(), 0666) +} + +func TestTextOnly(t *testing.T) { + ttx := "This is testing text, it is only a test. Do not be alarmed" + RunTest(t, "text-only", func() *core.Body { + b := core.NewBody() + b.Styler(func(s *styles.Style) { + s.Min.X.Ch(80) + }) + for range 200 { + core.NewText(b).SetText(ttx) + } + return b + }) +} diff --git a/text/paginate/render.go b/text/paginate/render.go new file mode 100644 index 0000000000..d685bec09a --- /dev/null +++ b/text/paginate/render.go @@ -0,0 +1,47 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paginate + +import ( + "io" + + "cogentcore.org/core/core" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint" + "cogentcore.org/core/paint/renderers/pdfrender" + "cogentcore.org/core/tree" +) + +// PDF generates PDF pages from given input content using given options, +// writing to the given writer. +func PDF(w io.Writer, opts Options, ins ...core.Widget) { + if len(ins) == 0 { + return + } + p := pager{opts: &opts, ins: ins} + p.paginate() + sc := core.NewScene() + sz := math32.Geom2DInt{} + sz.Size = opts.sizeDots.ToPointCeil() + sc.Resize(sz) + sc.MakeTextShaper() + pdr := paint.NewPDFRenderer(opts.sizeDots, &p.ctx).(*pdfrender.Renderer) + np := len(p.outs) + for i, p := range p.outs { + tree.MoveToParent(p, sc) + p.SetScene(sc) + sc.StyleTree() + sc.LayoutRenderScene() + + rend := sc.Painter.RenderDone() + pdr.RenderPage(w, rend) + break + if i < np-1 { + pdr.PDF.NewPage(opts.sizeDots.X, opts.sizeDots.Y) + } + sc.DeleteChildren() + } + pdr.PDF.Close() +} diff --git a/text/paginate/typegen.go b/text/paginate/typegen.go new file mode 100644 index 0000000000..a09908c724 --- /dev/null +++ b/text/paginate/typegen.go @@ -0,0 +1,11 @@ +// Code generated by "core generate -add-types"; DO NOT EDIT. + +package paginate + +import ( + "cogentcore.org/core/types" +) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.Options", IDName: "options", Doc: "Options has the parameters for pagination.", Fields: []types.Field{{Name: "PageSize", Doc: "PageSize specifies a standard page size, or Custom."}, {Name: "Units", Doc: "Units are the units in which size is specified.\nWill automatically be set if PageSize != Custom."}, {Name: "Size", Doc: "Size is the size in given units.\nWill automatically be set if PageSize != Custom."}, {Name: "Margins", Doc: "Margins specify the page margins in the size units."}, {Name: "Header", Doc: "Header is the header template string, with #\nreplaced with the page number\n adds a stretch element that can be used to accomplish\njustification: at start = right justify, at start and end = center"}, {Name: "Footer", Doc: "Footer is the footer template string, with #\nreplaced with the page number.\n adds a stretch element that can be used to accomplish\njustification: at start = right justify, at start and end = center"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.pager", IDName: "pager", Doc: "pager implements the pagination.", Fields: []types.Field{{Name: "opts"}, {Name: "ins"}, {Name: "outs"}}}) From ea9a39651cdf8c11e150cb7261bbf67d0583e214 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 11:59:31 +0200 Subject: [PATCH 12/99] pdf: multipage working --- paint/renderers/pdfrender/pdfrender.go | 31 +++++++++++++++++--------- text/paginate/paginate_test.go | 5 +++-- text/paginate/render.go | 8 +++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index 765b618cf8..b4d0d84d86 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -66,21 +66,34 @@ func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { // Render is the main rendering function. func (rs *Renderer) Render(r render.Render) render.Renderer { rs.buff = &bytes.Buffer{} - rs.RenderPage(rs.buff, r) - rs.PDF.Close() + rs.StartRender(rs.buff) + rs.RenderPage(r) + rs.EndRender() return rs } -// RenderPage is the main rendering function, rendering to a writer -func (rs *Renderer) RenderPage(w io.Writer, r render.Render) render.Renderer { - // pdf is in points +// StartRender creates the renderer. +func (rs *Renderer) StartRender(w io.Writer) { sx := rs.unitContext.Convert(float32(rs.size.X), rs.sizeUnits, units.UnitPt) sy := rs.unitContext.Convert(float32(rs.size.Y), rs.sizeUnits, units.UnitPt) rs.PDF = pdf.New(w, sx, sy, &rs.unitContext) rs.lyStack = nil - bg := rs.PDF.AddLayer("bg", true) - rs.PDF.BeginLayer(bg, math32.Identity2()) - rs.lyStack.Push(bg) +} + +// EndRender finishes the render +func (rs *Renderer) EndRender() { + rs.PDF.Close() +} + +// AddPage adds a new page of the same size. +func (rs *Renderer) AddPage() { + sx := rs.unitContext.Convert(float32(rs.size.X), rs.sizeUnits, units.UnitPt) + sy := rs.unitContext.Convert(float32(rs.size.Y), rs.sizeUnits, units.UnitPt) + rs.PDF.NewPage(sx, sy) +} + +// RenderPage renders the content to current PDF page +func (rs *Renderer) RenderPage(r render.Render) { for _, ri := range r { switch x := ri.(type) { case *render.Path: @@ -95,8 +108,6 @@ func (rs *Renderer) RenderPage(w io.Writer, r render.Render) render.Renderer { rs.PopContext(x) } } - rs.PDF.EndLayer() - return rs } func (rs *Renderer) PushLayer(m math32.Matrix2) int { diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index d988a4a6ac..1110f2f14b 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -6,6 +6,7 @@ package paginate import ( "bytes" + "fmt" "os" "path/filepath" "testing" @@ -39,8 +40,8 @@ func TestTextOnly(t *testing.T) { b.Styler(func(s *styles.Style) { s.Min.X.Ch(80) }) - for range 200 { - core.NewText(b).SetText(ttx) + for i := range 200 { + core.NewText(b).SetText(fmt.Sprintf("Line %d: %s", i, ttx)) } return b }) diff --git a/text/paginate/render.go b/text/paginate/render.go index d685bec09a..630d3f232a 100644 --- a/text/paginate/render.go +++ b/text/paginate/render.go @@ -28,6 +28,7 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { sc.Resize(sz) sc.MakeTextShaper() pdr := paint.NewPDFRenderer(opts.sizeDots, &p.ctx).(*pdfrender.Renderer) + pdr.StartRender(w) np := len(p.outs) for i, p := range p.outs { tree.MoveToParent(p, sc) @@ -36,12 +37,11 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { sc.LayoutRenderScene() rend := sc.Painter.RenderDone() - pdr.RenderPage(w, rend) - break + pdr.RenderPage(rend) if i < np-1 { - pdr.PDF.NewPage(opts.sizeDots.X, opts.sizeDots.Y) + pdr.AddPage() } sc.DeleteChildren() } - pdr.PDF.Close() + pdr.EndRender() } From 5e2f4fb98bdcd8f427f942df5dedc52b15eb5494 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 13:40:40 +0200 Subject: [PATCH 13/99] pdf: page layout working but doesn't look right. major update to Shaper / rich.Settings: it wasn't using the args passed in shaper anyway, and it is just cleaner to have one definitive location for global rich text settings (font names etc), so cleaned that up. --- core/frame.go | 5 +- core/meter.go | 2 +- core/scene.go | 10 ++- core/settings.go | 4 +- core/text.go | 6 +- core/textfield.go | 4 +- core/typegen.go | 2 +- paint/paint_test.go | 2 +- paint/pimage/pimage.go | 4 ++ paint/render/item.go | 12 ++++ paint/render/path.go | 4 ++ paint/render/render.go | 9 +++ paint/render/text.go | 4 ++ paint/text_test.go | 8 +-- styles/style.go | 9 +++ svg/text.go | 3 +- text/fonts/fontbrowser/glyph.go | 2 +- text/paginate/page.go | 63 +++++++++++++++++++ text/paginate/paginate.go | 22 +++---- text/paginate/paginate_test.go | 2 +- text/paginate/render.go | 8 +++ text/rich/settings.go | 16 ++--- text/rich/style.go | 2 +- text/shaped/shaper.go | 4 +- text/shaped/shapers/shapedgt/shaper.go | 18 +++--- text/shaped/shapers/shapedgt/wrap.go | 8 +-- text/shaped/shapers/shapedjs/metrics.go | 16 ++--- text/shaped/shapers/shapedjs/shaper.go | 24 +++---- text/tex/tex_test.go | 2 +- text/text/font.go | 6 +- text/textcore/layout.go | 2 +- text/textcore/render.go | 7 +-- xyz/text2d.go | 2 +- .../coresymbols/cogentcore_org-core-core.go | 15 +---- .../cogentcore_org-core-text-rich.go | 26 ++++---- 35 files changed, 215 insertions(+), 118 deletions(-) create mode 100644 text/paginate/page.go diff --git a/core/frame.go b/core/frame.go index e690931772..f46e3012cd 100644 --- a/core/frame.go +++ b/core/frame.go @@ -487,9 +487,6 @@ func (sp *Space) Init() { s.RenderBox = false s.Min.X.Ch(1) s.Min.Y.Em(1) - s.Padding.Zero() - s.Margin.Zero() - s.MaxBorder.Width.Zero() - s.Border.Width.Zero() + s.ZeroSpace() }) } diff --git a/core/meter.go b/core/meter.go index bf81e18bfe..589f798a74 100644 --- a/core/meter.go +++ b/core/meter.go @@ -147,7 +147,7 @@ func (m *Meter) Render() { if m.Text != "" { sty, tsty := m.Styles.NewRichText() tx, _ := htmltext.HTMLToRich([]byte(m.Text), sty, nil) - txt = m.Scene.TextShaper().WrapLines(tx, sty, tsty, &AppearanceSettings.Text, size) + txt = m.Scene.TextShaper().WrapLines(tx, sty, tsty, size) toff = txt.Bounds.Size().DivScalar(2) } diff --git a/core/scene.go b/core/scene.go index bd12cf8394..01727c1644 100644 --- a/core/scene.go +++ b/core/scene.go @@ -251,9 +251,13 @@ func (sc *Scene) renderContext() *renderContext { return sm.renderContext } -// MakeTextShaper makes our own text shaper for offline use -func (sc *Scene) MakeTextShaper() { - sc.textShaper = shaped.NewShaper() +// MakeTextShaper makes our own text shaper for offline use, +// if not already in place. +func (sc *Scene) MakeTextShaper() shaped.Shaper { + if sc.textShaper == nil { + sc.textShaper = shaped.NewShaper() + } + return sc.textShaper } // TextShaper returns the current [shaped.TextShaper], for text shaping. diff --git a/core/settings.go b/core/settings.go index 1247bfea08..34adf26649 100644 --- a/core/settings.go +++ b/core/settings.go @@ -270,7 +270,7 @@ type AppearanceSettingsData struct { //types:add // Text specifies text settings including the language, and the // font families for different styles of fonts. - Text rich.Settings + Text rich.SettingsData // only support closing the currently selected active tab; // if this is set to true, pressing the close button on other tabs @@ -366,7 +366,7 @@ func (as *AppearanceSettingsData) Apply() { //types:add if as.Highlighting == "" { as.Highlighting = "emacs" } - rich.DefaultSettings = as.Text + rich.Settings = as.Text // TODO(kai): move HiStyle to a separate text editor settings // if TheViewInterface != nil { diff --git a/core/text.go b/core/text.go index d95ebe2a59..8de8b424c0 100644 --- a/core/text.go +++ b/core/text.go @@ -420,7 +420,7 @@ func (tx *Text) configTextSize(sz math32.Vector2) { } sty, tsty := tx.Styles.NewRichText() tsty.Align, tsty.AlignV = text.Start, text.Start - tx.paintText = tx.Scene.TextShaper().WrapLines(tx.richText, sty, tsty, &AppearanceSettings.Text, sz) + tx.paintText = tx.Scene.TextShaper().WrapLines(tx.richText, sty, tsty, sz) } // configTextAlloc is used for determining how much space the text @@ -441,10 +441,10 @@ func (tx *Text) configTextAlloc(sz math32.Vector2) math32.Vector2 { if tsty.Align != text.Start && tsty.AlignV != text.Start { etxs := *tsty etxs.Align, etxs.AlignV = text.Start, text.Start - tx.paintText = tsh.WrapLines(tx.richText, sty, &etxs, &AppearanceSettings.Text, rsz) + tx.paintText = tsh.WrapLines(tx.richText, sty, &etxs, rsz) rsz = tx.paintText.Bounds.Size().Ceil() } - tx.paintText = tsh.WrapLines(tx.richText, sty, tsty, &AppearanceSettings.Text, rsz) + tx.paintText = tsh.WrapLines(tx.richText, sty, tsty, rsz) return tx.paintText.Bounds.Size().Ceil() } diff --git a/core/textfield.go b/core/textfield.go index f5d5f1ee79..198d42c7cf 100644 --- a/core/textfield.go +++ b/core/textfield.go @@ -1618,7 +1618,7 @@ func (tf *TextField) configTextSize(sz math32.Vector2) math32.Vector2 { etxs := *tsty etxs.Align, etxs.AlignV = text.Start, text.Start // only works with this tx := rich.NewText(sty, txt) - tf.renderAll = tf.Scene.TextShaper().WrapLines(tx, sty, &etxs, &AppearanceSettings.Text, sz) + tf.renderAll = tf.Scene.TextShaper().WrapLines(tx, sty, &etxs, sz) rsz := tf.renderAll.Bounds.Size().Ceil() return rsz } @@ -1741,7 +1741,7 @@ func (tf *TextField) layoutCurrent() { sty, tsty := tf.Styles.NewRichText() tsty.Color = colors.ToUniform(clr) tx := rich.NewText(sty, cur) - tf.renderVisible = tf.Scene.TextShaper().WrapLines(tx, sty, tsty, &AppearanceSettings.Text, availSz) + tf.renderVisible = tf.Scene.TextShaper().WrapLines(tx, sty, tsty, availSz) tf.renderedRange = tf.dispRange } diff --git a/core/typegen.go b/core/typegen.go index cc1e81145f..1f409a3d98 100644 --- a/core/typegen.go +++ b/core/typegen.go @@ -600,7 +600,7 @@ func NewPages(parent ...tree.Node) *Pages { return tree.New[Pages](parent...) } // Page is the currently open page. func (t *Pages) SetPage(v string) *Pages { t.Page = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Scene", IDName: "scene", Doc: "Scene contains a [Widget] tree, rooted in an embedded [Frame] layout,\nwhich renders into its own [paint.Painter]. The [Scene] is set in a\n[Stage], which the [Scene] has a pointer to.\n\nEach [Scene] contains state specific to its particular usage\nwithin a given [Stage] and overall rendering context, representing the unit\nof rendering in the Cogent Core framework.", Directives: []types.Directive{{Tool: "core", Directive: "no-new"}}, Methods: []types.Method{{Name: "standardContextMenu", Doc: "standardContextMenu adds standard context menu items for the [Scene].", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"m"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Body", Doc: "Body provides the main contents of scenes that use control Bars\nto allow the main window contents to be specified separately\nfrom that dynamic control content. When constructing scenes using\na [Body], you can operate directly on the [Body], which has wrappers\nfor most major Scene functions."}, {Name: "WidgetInit", Doc: "WidgetInit is a function called on every newly created [Widget].\nThis can be used to set global configuration and styling for all\nwidgets in conjunction with [App.SceneInit]."}, {Name: "Bars", Doc: "Bars are functions for creating control bars,\nattached to different sides of a [Scene]. Functions\nare called in forward order so first added are called first."}, {Name: "Data", Doc: "Data is the optional data value being represented by this scene.\nUsed e.g., for recycling views of a given item instead of creating new one."}, {Name: "SceneGeom", Doc: "Size and position relative to overall rendering context."}, {Name: "Painter", Doc: "painter for rendering all widgets in the scene."}, {Name: "Events", Doc: "event manager for this scene."}, {Name: "Stage", Doc: "current stage in which this Scene is set."}, {Name: "Animations", Doc: "Animations are the currently active [Animation]s in this scene."}, {Name: "renderBBoxes", Doc: "renderBBoxes indicates to render colored bounding boxes for all of the widgets\nin the scene. This is enabled by the [Inspector] in select element mode."}, {Name: "renderBBoxHue", Doc: "renderBBoxHue is current hue for rendering bounding box in [Scene.RenderBBoxes] mode."}, {Name: "selectedWidget", Doc: "selectedWidget is the currently selected/hovered widget through the [Inspector] selection mode\nthat should be highlighted with a background color."}, {Name: "selectedWidgetChan", Doc: "selectedWidgetChan is the channel on which the selected widget through the inspect editor\nselection mode is transmitted to the inspect editor after the user is done selecting."}, {Name: "renderer", Doc: "source renderer for rendering the scene"}, {Name: "lastRender", Doc: "lastRender captures key params from last render.\nIf different then a new ApplyStyleScene is needed."}, {Name: "showIter", Doc: "showIter counts up at start of showing a Scene\nto trigger Show event and other steps at start of first show"}, {Name: "directRenders", Doc: "directRenders are widgets that render directly to the [RenderWindow]\ninstead of rendering into the Scene Painter."}, {Name: "flags", Doc: "flags are atomic bit flags for [Scene] state."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Scene", IDName: "scene", Doc: "Scene contains a [Widget] tree, rooted in an embedded [Frame] layout,\nwhich renders into its own [paint.Painter]. The [Scene] is set in a\n[Stage], which the [Scene] has a pointer to.\n\nEach [Scene] contains state specific to its particular usage\nwithin a given [Stage] and overall rendering context, representing the unit\nof rendering in the Cogent Core framework.", Directives: []types.Directive{{Tool: "core", Directive: "no-new"}}, Methods: []types.Method{{Name: "standardContextMenu", Doc: "standardContextMenu adds standard context menu items for the [Scene].", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"m"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Body", Doc: "Body provides the main contents of scenes that use control Bars\nto allow the main window contents to be specified separately\nfrom that dynamic control content. When constructing scenes using\na [Body], you can operate directly on the [Body], which has wrappers\nfor most major Scene functions."}, {Name: "WidgetInit", Doc: "WidgetInit is a function called on every newly created [Widget].\nThis can be used to set global configuration and styling for all\nwidgets in conjunction with [App.SceneInit]."}, {Name: "Bars", Doc: "Bars are functions for creating control bars,\nattached to different sides of a [Scene]. Functions\nare called in forward order so first added are called first."}, {Name: "Data", Doc: "Data is the optional data value being represented by this scene.\nUsed e.g., for recycling views of a given item instead of creating new one."}, {Name: "SceneGeom", Doc: "Size and position relative to overall rendering context."}, {Name: "Painter", Doc: "painter for rendering all widgets in the scene."}, {Name: "Events", Doc: "event manager for this scene."}, {Name: "Stage", Doc: "current stage in which this Scene is set."}, {Name: "Animations", Doc: "Animations are the currently active [Animation]s in this scene."}, {Name: "renderBBoxes", Doc: "renderBBoxes indicates to render colored bounding boxes for all of the widgets\nin the scene. This is enabled by the [Inspector] in select element mode."}, {Name: "renderBBoxHue", Doc: "renderBBoxHue is current hue for rendering bounding box in [Scene.RenderBBoxes] mode."}, {Name: "selectedWidget", Doc: "selectedWidget is the currently selected/hovered widget through the [Inspector] selection mode\nthat should be highlighted with a background color."}, {Name: "selectedWidgetChan", Doc: "selectedWidgetChan is the channel on which the selected widget through the inspect editor\nselection mode is transmitted to the inspect editor after the user is done selecting."}, {Name: "renderer", Doc: "source renderer for rendering the scene"}, {Name: "lastRender", Doc: "lastRender captures key params from last render.\nIf different then a new ApplyStyleScene is needed."}, {Name: "showIter", Doc: "showIter counts up at start of showing a Scene\nto trigger Show event and other steps at start of first show"}, {Name: "directRenders", Doc: "directRenders are widgets that render directly to the [RenderWindow]\ninstead of rendering into the Scene Painter."}, {Name: "textShaper", Doc: "this is our own text shaper in case we don't have a render context"}, {Name: "flags", Doc: "flags are atomic bit flags for [Scene] state."}}}) // SetWidgetInit sets the [Scene.WidgetInit]: // WidgetInit is a function called on every newly created [Widget]. diff --git a/paint/paint_test.go b/paint/paint_test.go index 9ce16f71c7..9b2c3e4246 100644 --- a/paint/paint_test.go +++ b/paint/paint_test.go @@ -110,7 +110,7 @@ func TestRender(t *testing.T) { tx, err := htmltext.HTMLToRich([]byte("This is HTML formatted text"), fsty, nil) assert.NoError(t, err) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.DefaultSettings, math32.Vec2(100, 60)) + lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, math32.Vec2(100, 60)) // if tsz.X != 100 || tsz.Y != 60 { // t.Errorf("unexpected text size: %v", tsz) // } diff --git a/paint/pimage/pimage.go b/paint/pimage/pimage.go index 61ba5c5465..8225ced717 100644 --- a/paint/pimage/pimage.go +++ b/paint/pimage/pimage.go @@ -69,6 +69,10 @@ type Params struct { func (pr *Params) IsRenderItem() {} +func (p *Params) String() string { + return "image: " + p.Cmd.String() +} + // NewClear returns a new Clear that renders entire image with given source image. func NewClear(src image.Image, sp image.Point, op draw.Op) *Params { pr := &Params{Cmd: Draw, Rect: image.Rectangle{}, Source: imagex.WrapJS(src), SourcePos: sp, Op: op} diff --git a/paint/render/item.go b/paint/render/item.go index 3800f34bf8..f83dae8c98 100644 --- a/paint/render/item.go +++ b/paint/render/item.go @@ -4,9 +4,13 @@ package render +import "fmt" + // Item is a union interface for render items: // [Path], [Text], [Image], and [ContextPush]. type Item interface { + fmt.Stringer + IsRenderItem() } @@ -20,11 +24,19 @@ type ContextPush struct { func (p *ContextPush) IsRenderItem() { } +func (p *ContextPush) String() string { + return "ctx-push: " + p.Context.Transform.String() +} + // ContextPop is a [Context] pop render item, which can be used by renderers // that track group structure (e.g., SVG). type ContextPop struct { } +func (p *ContextPop) String() string { + return "ctx-pop" +} + // interface assertion. func (p *ContextPop) IsRenderItem() { } diff --git a/paint/render/path.go b/paint/render/path.go index 73c1811a03..c8900af264 100644 --- a/paint/render/path.go +++ b/paint/render/path.go @@ -34,3 +34,7 @@ func NewPath(pt ppath.Path, sty *styles.Paint, ctx *Context) *Path { // interface assertion. func (p *Path) IsRenderItem() {} + +func (p *Path) String() string { + return "path: " + p.Path.String() +} diff --git a/paint/render/render.go b/paint/render/render.go index 1ed1405f7c..e84dab4cd9 100644 --- a/paint/render/render.go +++ b/paint/render/render.go @@ -7,6 +7,7 @@ package render import ( "reflect" "slices" + "strings" "cogentcore.org/core/base/reflectx" ) @@ -47,3 +48,11 @@ func (pr *Render) Add(item ...Item) *Render { func (pr *Render) Reset() { *pr = (*pr)[:0] } + +func (pr *Render) String() string { + var b strings.Builder + for _, it := range *pr { + b.WriteString(it.String() + "\n") + } + return b.String() +} diff --git a/paint/render/text.go b/paint/render/text.go index 9f47eae8de..2fd66f653a 100644 --- a/paint/render/text.go +++ b/paint/render/text.go @@ -36,3 +36,7 @@ func NewText(txt *shaped.Lines, sty *styles.Paint, ctx *Context, pos math32.Vect // interface assertion. func (tx *Text) IsRenderItem() {} + +func (tx *Text) String() string { + return "text: " + tx.Text.String() +} diff --git a/paint/text_test.go b/paint/text_test.go index 989c888e99..7fce7f4ccf 100644 --- a/paint/text_test.go +++ b/paint/text_test.go @@ -44,7 +44,7 @@ func TestTextAscii(t *testing.T) { y := float32(5) for _, ts := range lines { tx := rich.NewText(fsty, []rune(ts)) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.DefaultSettings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) pos := math32.Vector2{5, y} pc.DrawText(lns, pos) y += 20 @@ -66,7 +66,7 @@ func TestTextMarkup(t *testing.T) { tx, err := htmltext.HTMLToRich([]byte("This is HTML formatted text with underline and strikethrough"), fsty, nil) assert.NoError(t, err) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.DefaultSettings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) lns.SelectRegion(textpos.Range{Start: 5, End: 20}) // if tsz.X != 100 || tsz.Y != 40 { // t.Errorf("unexpected text size: %v", tsz) @@ -104,7 +104,7 @@ func TestTextLines(t *testing.T) { tx.AddSpan(&du, []rune("Dotted Underline")).AddSpan(fsty, []rune(" and ")).AddSpan(&uu, []rune("Underline")) tx.AddSpan(fsty, []rune(" and ")).AddSpan(&ol, []rune("Overline")) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.DefaultSettings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) pos := math32.Vector2{10, 10} // pc.Paint.Transform = math32.Rotate2DAround(math32.DegToRad(-45), pos) pc.DrawText(lns, pos) @@ -135,7 +135,7 @@ func TestTextColors(t *testing.T) { tx.AddSpan(&rd, []rune("Red")).AddSpan(fsty, []rune(" and ")).AddSpan(&bl, []rune("Blue")) tx.AddSpan(fsty, []rune(" and ")).AddSpan(&gr, []rune("Green")) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.DefaultSettings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) pos := math32.Vector2{10, 10} // pc.Paint.Transform = math32.Rotate2DAround(math32.DegToRad(-45), pos) pc.DrawText(lns, pos) diff --git a/styles/style.go b/styles/style.go index 043f24fbd6..e731de8358 100644 --- a/styles/style.go +++ b/styles/style.go @@ -248,6 +248,15 @@ func (s *Style) Defaults() { s.Text.Defaults() } +// ZeroSpace sets all the spacing elements to zero: Padding, Border, Margin, Gap +func (s *Style) ZeroSpace() { + s.Padding.Zero() + s.Margin.Zero() + s.MaxBorder.Width.Zero() + s.Border.Width.Zero() + s.Gap.Zero() +} + // VirtualKeyboards are all of the supported virtual keyboard types // to display on mobile platforms. type VirtualKeyboards int32 //enums:enum -trim-prefix Keyboard -transform kebab diff --git a/svg/text.go b/svg/text.go index 4cb4157f81..d1306d6535 100644 --- a/svg/text.go +++ b/svg/text.go @@ -8,7 +8,6 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/math32" "cogentcore.org/core/text/htmltext" - "cogentcore.org/core/text/rich" "cogentcore.org/core/text/shaped" "cogentcore.org/core/text/text" ) @@ -92,7 +91,7 @@ func (g *Text) LocalBBox(sv *SVG) math32.Box2 { tx, _ := htmltext.HTMLToRich([]byte(g.Text), &fs, nil) // fmt.Println(tx) sz := math32.Vec2(10000, 10000) // no wrapping!! - g.TextShaped = sv.TextShaper.WrapLines(tx, &fs, &pc.Text, &rich.DefaultSettings, sz) + g.TextShaped = sv.TextShaper.WrapLines(tx, &fs, &pc.Text, sz) baseOff := g.TextShaped.Lines[0].Offset g.TextShaped.StartAtBaseline() // remove top-left offset return g.TextShaped.Bounds.Translate(g.Pos.Sub(baseOff)) diff --git a/text/fonts/fontbrowser/glyph.go b/text/fonts/fontbrowser/glyph.go index d8e46ba7ab..827f254a16 100644 --- a/text/fonts/fontbrowser/glyph.go +++ b/text/fonts/fontbrowser/glyph.go @@ -131,7 +131,7 @@ func (gi *Glyph) drawShaped(pc *paint.Painter) { sty.Size = float32(msz) / tsty.FontSize.Dots sty.Size *= 0.85 tx := rich.NewText(sty, []rune{gi.Rune}) - lns := gi.Scene.TextShaper().WrapLines(tx, sty, tsty, &core.AppearanceSettings.Text, sz) + lns := gi.Scene.TextShaper().WrapLines(tx, sty, tsty, sz) off := math32.Vec2(0, 0) if msz > 200 { o := 0.2 * float32(msz) diff --git a/text/paginate/page.go b/text/paginate/page.go new file mode 100644 index 0000000000..992e0a0e93 --- /dev/null +++ b/text/paginate/page.go @@ -0,0 +1,63 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paginate + +import ( + "fmt" + + "cogentcore.org/core/core" + "cogentcore.org/core/math32" + "cogentcore.org/core/styles" +) + +func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { + styMinMax := func(s *styles.Style, x, y float32) { + s.ZeroSpace() + s.Min.X.Dot(x) + s.Min.Y.Dot(y) + s.Max.X.Dot(x) + s.Max.Y.Dot(y) + } + pn := fmt.Sprintf("page-%d", len(p.outs)+1) + page = core.NewFrame() + page.SetName(pn) + page.Styler(func(s *styles.Style) { + s.Direction = styles.Column + styMinMax(s, p.opts.sizeDots.X, p.opts.sizeDots.Y) + }) + hdr := core.NewFrame(page) + hdr.SetName("header") + hdr.Styler(func(s *styles.Style) { + s.Direction = styles.Column + styMinMax(s, p.opts.sizeDots.X, p.opts.margDots.Top) + }) + bodRow := core.NewFrame(page) + bodRow.SetName("body-row") + bodRow.Styler(func(s *styles.Style) { + s.Direction = styles.Row + styMinMax(s, p.opts.sizeDots.X, p.opts.bodyDots.Y) + }) + ftr := core.NewFrame(page) + ftr.SetName("footer") + ftr.Styler(func(s *styles.Style) { + s.Direction = styles.Column + styMinMax(s, p.opts.sizeDots.X, p.opts.margDots.Bottom) + }) + lmar := core.NewFrame(bodRow) + lmar.SetName("left-margin") + lmar.Styler(func(s *styles.Style) { + s.Direction = styles.Column + styMinMax(s, p.opts.margDots.Left, p.opts.bodyDots.Y) + }) + body = core.NewFrame(bodRow) + body.SetName("body") + body.Styler(func(s *styles.Style) { + s.Direction = styles.Column + styMinMax(s, p.opts.bodyDots.X, p.opts.bodyDots.Y) + s.Gap.X.Dot(gap.X) + s.Gap.Y.Dot(gap.Y) + }) + return +} diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index affc2a4d41..dac5c6eafe 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -6,7 +6,6 @@ package paginate import ( "cogentcore.org/core/core" - "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/tree" ) @@ -42,6 +41,7 @@ func (p *pager) paginate() { cIn := widg(p.ins[ii]) cw := widg(cIn.Child(ci)) atEnd := false + gap := cIn.Styles.Gap.Dots().Floor() for { // find height ht := float32(0) @@ -51,11 +51,11 @@ func (p *pager) paginate() { atEnd = true break } - gp := cIn.Styles.Gap.Dots().Floor() - ht += cw.Geom.Size.Actual.Total.Y + gp.Y + ht += cw.Geom.Size.Actual.Total.Y if ht >= p.opts.bodyDots.Y { break } + ht += gap.Y ws = append(ws, cw.This.(core.Widget)) ci++ if ci >= cIn.NumChildren() { @@ -66,24 +66,18 @@ func (p *pager) paginate() { break } cIn = widg(p.ins[ii]) + gap = cIn.Styles.Gap.Dots().Floor() // todo: need to track this per parent input } cw = widg(cIn.Child(ci)) } // todo: rearrange elements to put text at bottom and non-text at top + // now transfer over to frame - cOut := core.NewFrame() - cOut.Styler(func(s *styles.Style) { - s.Direction = styles.Column - s.Min.X.Dot(p.opts.sizeDots.X) - s.Min.Y.Dot(p.opts.sizeDots.Y) - s.Max.X.Dot(p.opts.sizeDots.X) - s.Max.Y.Dot(p.opts.sizeDots.Y) - }) + page, body := p.newPage(gap) for _, w := range ws { - tree.MoveToParent(w, cOut) + tree.MoveToParent(w, body) } - // fmt.Println("ht:", ht, "n:", len(ws)) - p.outs = append(p.outs, cOut) + p.outs = append(p.outs, page) if atEnd { break } diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index 1110f2f14b..a667561042 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -34,7 +34,7 @@ func RunTest(t *testing.T, nm string, f func() *core.Body) { } func TestTextOnly(t *testing.T) { - ttx := "This is testing text, it is only a test. Do not be alarmed" + ttx := "This is testing text, it is only a test. Do not be alarmed. The text must be at least a certain amount wide so that we can see how it flows up to the margin and judge the typesetting qualities etc." RunTest(t, "text-only", func() *core.Body { b := core.NewBody() b.Styler(func(s *styles.Style) { diff --git a/text/paginate/render.go b/text/paginate/render.go index 630d3f232a..f5f6ac99fd 100644 --- a/text/paginate/render.go +++ b/text/paginate/render.go @@ -5,12 +5,14 @@ package paginate import ( + "fmt" "io" "cogentcore.org/core/core" "cogentcore.org/core/math32" "cogentcore.org/core/paint" "cogentcore.org/core/paint/renderers/pdfrender" + "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -20,6 +22,11 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { if len(ins) == 0 { return } + fmt.Println("\n\n#######################\nREDOING\n#######################") + rich.Settings.SansSerif = "Arial" + in0 := ins[0].AsWidget() + in0.Scene.StyleTree() + in0.Scene.LayoutRenderScene() p := pager{opts: &opts, ins: ins} p.paginate() sc := core.NewScene() @@ -27,6 +34,7 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { sz.Size = opts.sizeDots.ToPointCeil() sc.Resize(sz) sc.MakeTextShaper() + pdr := paint.NewPDFRenderer(opts.sizeDots, &p.ctx).(*pdfrender.Renderer) pdr.StartRender(w) np := len(p.outs) diff --git a/text/rich/settings.go b/text/rich/settings.go index 5cba0377b7..4227323b61 100644 --- a/text/rich/settings.go +++ b/text/rich/settings.go @@ -11,12 +11,14 @@ import ( ) func init() { - DefaultSettings.Defaults() + Settings.Defaults() } -// DefaultSettings contains the default global text settings. -// This will be updated from rich.DefaultSettings. -var DefaultSettings Settings +// Settings contains the global text settings, +// for language, script and font names to use. +// To use different settings temporarily, save current +// and swap. +var Settings SettingsData // FontName is a special string that provides a font chooser. // It is aliased to [core.FontName] as well. @@ -25,7 +27,7 @@ type FontName string // Settings holds the global settings for rich text styling, // including language, script, and preferred font faces for // each category of font. -type Settings struct { +type SettingsData struct { // Language is the preferred language used for rendering text. Language language.Language @@ -96,7 +98,7 @@ type Settings struct { Fangsong FontName } -func (rts *Settings) Defaults() { +func (rts *SettingsData) Defaults() { rts.Language = language.DefaultLanguage() rts.SansSerif = "Noto Sans" rts.Monospace = "Roboto Mono" @@ -130,7 +132,7 @@ func FamiliesToList(fam string) []string { } // Family returns the font family specified by the given [Family] enum. -func (rts *Settings) Family(fam Family) string { +func (rts *SettingsData) Family(fam Family) string { switch fam { case SansSerif: return AddFamily(rts.SansSerif, `-apple-system, BlinkMacSystemFont, "Segoe UI", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif, emoji`) diff --git a/text/rich/style.go b/text/rich/style.go index 82caa6b0d5..abe8b703f7 100644 --- a/text/rich/style.go +++ b/text/rich/style.go @@ -118,7 +118,7 @@ func (s *Style) InheritFields(parent *Style) { // FontFamily returns the font family name(s) based on [Style.Family] and the // values specified in the given [Settings]. -func (s *Style) FontFamily(ctx *Settings) string { +func (s *Style) FontFamily(ctx *SettingsData) string { return ctx.Family(s.Family) } diff --git a/text/shaped/shaper.go b/text/shaped/shaper.go index c16e4e46d6..ee95c35650 100644 --- a/text/shaped/shaper.go +++ b/text/shaped/shaper.go @@ -36,7 +36,7 @@ type Shaper interface { // The results are only valid until the next call to Shape or WrapParagraph: // use slices.Clone if needed longer than that. // This is called under a mutex lock, so it is safe for parallel use. - Shape(tx rich.Text, tsty *text.Style, rts *rich.Settings) []Run + Shape(tx rich.Text, tsty *text.Style) []Run // WrapLines performs line wrapping and shaping on the given rich text source, // using the given style information, where the [rich.Style] provides the default @@ -46,7 +46,7 @@ type Shaper interface { // source text, and wrapped separately. For horizontal text, the Lines will render with // a position offset at the upper left corner of the overall bounding box of the text. // This is called under a mutex lock, so it is safe for parallel use. - WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, rts *rich.Settings, size math32.Vector2) *Lines + WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, size math32.Vector2) *Lines // FontFamilies returns a list of available font family names on this system. FontList() []fonts.Info diff --git a/text/shaped/shapers/shapedgt/shaper.go b/text/shaped/shapers/shapedgt/shaper.go index 986674f6c8..8666cead9a 100644 --- a/text/shaped/shapers/shapedgt/shaper.go +++ b/text/shaped/shapers/shapedgt/shaper.go @@ -83,18 +83,18 @@ func (sh *Shaper) FontMap() *fontscan.FontMap { // The results are only valid until the next call to Shape or WrapParagraph: // use slices.Clone if needed longer than that. // This is called under a mutex lock, so it is safe for parallel use. -func (sh *Shaper) Shape(tx rich.Text, tsty *text.Style, rts *rich.Settings) []shaped.Run { +func (sh *Shaper) Shape(tx rich.Text, tsty *text.Style) []shaped.Run { sh.Lock() defer sh.Unlock() - return sh.ShapeText(tx, tsty, rts, tx.Join()) + return sh.ShapeText(tx, tsty, tx.Join()) } // ShapeText shapes the spans in the given text using given style and settings, // returning [shaped.Run] results. // This should already have the mutex lock, and is used by shapedjs but is // not an end-user call. -func (sh *Shaper) ShapeText(tx rich.Text, tsty *text.Style, rts *rich.Settings, txt []rune) []shaped.Run { - outs := sh.ShapeTextOutput(tx, tsty, rts, txt) +func (sh *Shaper) ShapeText(tx rich.Text, tsty *text.Style, txt []rune) []shaped.Run { + outs := sh.ShapeTextOutput(tx, tsty, txt) runs := make([]shaped.Run, len(outs)) for i := range outs { run := &Run{Output: outs[i]} @@ -118,7 +118,7 @@ func (sh *Shaper) ShapeText(tx rich.Text, tsty *text.Style, rts *rich.Settings, // returning raw go-text [shaping.Output]. // This should already have the mutex lock, and is used by shapedjs but is // not an end-user call. -func (sh *Shaper) ShapeTextOutput(tx rich.Text, tsty *text.Style, rts *rich.Settings, txt []rune) []shaping.Output { +func (sh *Shaper) ShapeTextOutput(tx rich.Text, tsty *text.Style, txt []rune) []shaping.Output { if tx.Len() == 0 { return nil } @@ -144,7 +144,7 @@ func (sh *Shaper) ShapeTextOutput(tx rich.Text, tsty *text.Style, rts *rich.Sett si++ // skip the end special continue } - q := StyleToQuery(sty, tsty, rts) + q := StyleToQuery(sty, tsty) sh.fontMap.SetQuery(q) in.Text = txt @@ -153,8 +153,8 @@ func (sh *Shaper) ShapeTextOutput(tx rich.Text, tsty *text.Style, rts *rich.Sett in.Direction = shaped.GoTextDirection(sty.Direction, tsty) fsz := tsty.FontHeight(sty) in.Size = math32.ToFixed(fsz) - in.Script = rts.Script - in.Language = rts.Language + in.Script = rich.Settings.Script + in.Language = rich.Settings.Language ins := sh.splitter.Split(in, sh.fontMap) // this is essential for _, in := range ins { @@ -219,7 +219,7 @@ func DirectionAdvance(dir di.Direction, pos fixed.Point26_6, adv fixed.Int26_6) } // StyleToQuery translates the rich.Style to go-text fontscan.Query parameters. -func StyleToQuery(sty *rich.Style, tsty *text.Style, rts *rich.Settings) fontscan.Query { +func StyleToQuery(sty *rich.Style, tsty *text.Style) fontscan.Query { q := fontscan.Query{} fam := tsty.FontFamily(sty) q.Families = rich.FamiliesToList(fam) diff --git a/text/shaped/shapers/shapedgt/wrap.go b/text/shaped/shapers/shapedgt/wrap.go index 64756014a4..309dcb5fba 100644 --- a/text/shaped/shapers/shapedgt/wrap.go +++ b/text/shaped/shapers/shapedgt/wrap.go @@ -24,7 +24,7 @@ import ( // source text, and wrapped separately. For horizontal text, the Lines will render with // a position offset at the upper left corner of the overall bounding box of the text. // This is called under a mutex lock, so it is safe for parallel use. -func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, rts *rich.Settings, size math32.Vector2) *shaped.Lines { +func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, size math32.Vector2) *shaped.Lines { sh.Lock() defer sh.Unlock() if tsty.FontSize.Dots == 0 { @@ -32,14 +32,14 @@ func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, } txt := tx.Join() - outs := sh.ShapeTextOutput(tx, tsty, rts, txt) - lines, truncated := sh.WrapLinesOutput(outs, txt, tx, defSty, tsty, rts, size) + outs := sh.ShapeTextOutput(tx, tsty, txt) + lines, truncated := sh.WrapLinesOutput(outs, txt, tx, defSty, tsty, size) return sh.LinesBounds(lines, truncated, tx, defSty, tsty, size) } // This should already have the mutex lock, and is used by shapedjs but is // not an end-user call. Returns new lines and number of truncations. -func (sh *Shaper) WrapLinesOutput(outs []shaping.Output, txt []rune, tx rich.Text, defSty *rich.Style, tsty *text.Style, rts *rich.Settings, size math32.Vector2) ([]shaping.Line, int) { +func (sh *Shaper) WrapLinesOutput(outs []shaping.Output, txt []rune, tx rich.Text, defSty *rich.Style, tsty *text.Style, size math32.Vector2) ([]shaping.Line, int) { lht := tsty.LineHeightDots(defSty) dir := shaped.GoTextDirection(rich.Default, tsty) diff --git a/text/shaped/shapers/shapedjs/metrics.go b/text/shaped/shapers/shapedjs/metrics.go index a5f1250551..fdd446de9f 100644 --- a/text/shaped/shapers/shapedjs/metrics.go +++ b/text/shaped/shapers/shapedjs/metrics.go @@ -18,10 +18,10 @@ import ( // has the font ascent and descent information, and the BoundsBox() method returns a full // bounding box for the given font, centered at the baseline. // This is called under a mutex lock, so it is safe for parallel use. -func (sh *Shaper) FontSize(r rune, sty *rich.Style, tsty *text.Style, rts *rich.Settings) shaped.Run { +func (sh *Shaper) FontSize(r rune, sty *rich.Style, tsty *text.Style) shaped.Run { sh.Lock() defer sh.Unlock() - return sh.fontSize(r, sty, tsty, rts) + return sh.fontSize(r, sty, tsty) } // LineHeight returns the line height for given font and text style. @@ -29,27 +29,27 @@ func (sh *Shaper) FontSize(r rune, sty *rich.Style, tsty *text.Style, rts *rich. // It includes the [text.Style] LineHeight multiplier on the natural // font-derived line height, which is not generally the same as the font size. // This is called under a mutex lock, so it is safe for parallel use. -func (sh *Shaper) LineHeight(sty *rich.Style, tsty *text.Style, rts *rich.Settings) float32 { +func (sh *Shaper) LineHeight(sty *rich.Style, tsty *text.Style) float32 { sh.Lock() defer sh.Unlock() - return sh.lineHeight(sty, tsty, rts) + return sh.lineHeight(sty, tsty) } // fontSize returns the font shape sizing information for given font and text style, // using given rune (often the letter 'm'). The GlyphBounds field of the [Run] result // has the font ascent and descent information, and the BoundsBox() method returns a full // bounding box for the given font, centered at the baseline. -func (sh *Shaper) fontSize(r rune, sty *rich.Style, tsty *text.Style, rts *rich.Settings) shaped.Run { +func (sh *Shaper) fontSize(r rune, sty *rich.Style, tsty *text.Style) shaped.Run { tx := rich.NewText(sty, []rune{r}) - return sh.shapeAdjust(tx, tsty, rts, []rune{r})[0] + return sh.shapeAdjust(tx, tsty, []rune{r})[0] } // lineHeight returns the line height for given font and text style. // For vertical text directions, this is actually the line width. // It includes the [text.Style] LineHeight multiplier on the natural // font-derived line height, which is not generally the same as the font size. -func (sh *Shaper) lineHeight(sty *rich.Style, tsty *text.Style, rts *rich.Settings) float32 { - run := sh.fontSize('M', sty, tsty, rts) +func (sh *Shaper) lineHeight(sty *rich.Style, tsty *text.Style) float32 { + run := sh.fontSize('M', sty, tsty) bb := run.LineBounds() dir := shaped.GoTextDirection(rich.Default, tsty) if dir.IsVertical() { diff --git a/text/shaped/shapers/shapedjs/shaper.go b/text/shaped/shapers/shapedjs/shaper.go index 69d08dd713..c6fbca8ca8 100644 --- a/text/shaped/shapers/shapedjs/shaper.go +++ b/text/shaped/shapers/shapedjs/shaper.go @@ -56,29 +56,29 @@ func NewShaper() shaped.Shaper { // The results are only valid until the next call to Shape or WrapParagraph: // use slices.Clone if needed longer than that. // This is called under a mutex lock, so it is safe for parallel use. -func (sh *Shaper) Shape(tx rich.Text, tsty *text.Style, rts *rich.Settings) []shaped.Run { +func (sh *Shaper) Shape(tx rich.Text, tsty *text.Style) []shaped.Run { sh.Lock() defer sh.Unlock() - return sh.shapeAdjust(tx, tsty, rts, tx.Join()) + return sh.shapeAdjust(tx, tsty, tx.Join()) } // shapeAdjust turns given input spans into [Runs] of rendered text, // using given context needed for complete styling. // The results are only valid until the next call to Shape or WrapParagraph: // use slices.Clone if needed longer than that. -func (sh *Shaper) shapeAdjust(tx rich.Text, tsty *text.Style, rts *rich.Settings, txt []rune) []shaped.Run { - return sh.adjustRuns(sh.ShapeText(tx, tsty, rts, txt), tx, tsty, rts) +func (sh *Shaper) shapeAdjust(tx rich.Text, tsty *text.Style, txt []rune) []shaped.Run { + return sh.adjustRuns(sh.ShapeText(tx, tsty, txt), tx, tsty) } // adjustRuns adjusts the given run metrics based on the html measureText results. // This should already have the mutex lock, and is used by shapedjs but is // not an end-user call. -func (sh *Shaper) adjustRuns(runs []shaped.Run, tx rich.Text, tsty *text.Style, rts *rich.Settings) []shaped.Run { +func (sh *Shaper) adjustRuns(runs []shaped.Run, tx rich.Text, tsty *text.Style) []shaped.Run { for _, run := range runs { grun := run.(*shapedgt.Run) out := &grun.Output fnt := &grun.Font - sh.adjustOutput(out, fnt, tx, tsty, rts) + sh.adjustOutput(out, fnt, tx, tsty) } return runs } @@ -91,7 +91,7 @@ func (sh *Shaper) adjustRuns(runs []shaped.Run, tx rich.Text, tsty *text.Style, // source text, and wrapped separately. For horizontal text, the Lines will render with // a position offset at the upper left corner of the overall bounding box of the text. // This is called under a mutex lock, so it is safe for parallel use. -func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, rts *rich.Settings, size math32.Vector2) *shaped.Lines { +func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, size math32.Vector2) *shaped.Lines { sh.Lock() defer sh.Unlock() if tsty.FontSize.Dots == 0 { @@ -101,22 +101,22 @@ func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, txt := tx.Join() // sptx := tx.Clone() // sptx.SplitSpaces() // no advantage to doing this - outs := sh.ShapeTextOutput(tx, tsty, rts, txt) + outs := sh.ShapeTextOutput(tx, tsty, txt) for oi := range outs { out := &outs[oi] si, _, _ := tx.Index(out.Runes.Offset) sty, _ := tx.Span(si) fnt := text.NewFont(sty, tsty) - sh.adjustOutput(out, fnt, tx, tsty, rts) + sh.adjustOutput(out, fnt, tx, tsty) } - lines, truncated := sh.WrapLinesOutput(outs, txt, tx, defSty, tsty, rts, size) + lines, truncated := sh.WrapLinesOutput(outs, txt, tx, defSty, tsty, size) for _, lno := range lines { for oi := range lno { out := &lno[oi] si, _, _ := tx.Index(out.Runes.Offset) sty, _ := tx.Span(si) fnt := text.NewFont(sty, tsty) - sh.adjustOutput(out, fnt, tx, tsty, rts) + sh.adjustOutput(out, fnt, tx, tsty) } } return sh.LinesBounds(lines, truncated, tx, defSty, tsty, size) @@ -125,7 +125,7 @@ func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, // adjustOutput adjusts the given run metrics based on the html measureText results. // This should already have the mutex lock, and is used by shapedjs but is // not an end-user call. -func (sh *Shaper) adjustOutput(out *shaping.Output, fnt *text.Font, tx rich.Text, tsty *text.Style, rts *rich.Settings) { +func (sh *Shaper) adjustOutput(out *shaping.Output, fnt *text.Font, tx rich.Text, tsty *text.Style) { rng := textpos.Range{out.Runes.Offset, out.Runes.Offset + out.Runes.Count} si, sn, ri := tx.Index(rng.Start) sty, stx := tx.Span(si) diff --git a/text/tex/tex_test.go b/text/tex/tex_test.go index 236225ebf2..74195623eb 100644 --- a/text/tex/tex_test.go +++ b/text/tex/tex_test.go @@ -74,7 +74,7 @@ func TestTex(t *testing.T) { // reference text // sh := shaped.NewShaper() // tx := rich.NewText(&pc.Font, []rune("a=x")) - // lns := sh.WrapLines(tx, &pc.Font, &pc.Text, &rich.DefaultSettings, math32.Vec2(1000, 50)) + // lns := sh.WrapLines(tx, &pc.Font, &pc.Text, &rich.Settings, math32.Vec2(1000, 50)) // pc.DrawText(lns, math32.Vec2(0, 70)) }) } diff --git a/text/text/font.go b/text/text/font.go index 1493dafc3c..9e2776c1f7 100644 --- a/text/text/font.go +++ b/text/text/font.go @@ -19,7 +19,7 @@ type Font struct { Size float32 // Family is a nonstandard family name: if standard, then empty, - // and value is determined by [rich.DefaultSettings] and Style.Family. + // and value is determined by [rich.Settings] and Style.Family. Family string } @@ -40,12 +40,12 @@ func (fn *Font) Style(tsty *Style) *rich.Style { } // FontFamily returns the string value of the font Family for given [rich.Style], -// using [text.Style] CustomFont or [rich.DefaultSettings] values. +// using [text.Style] CustomFont or [rich.Settings] values. func (ts *Style) FontFamily(sty *rich.Style) string { if sty.Family == rich.Custom { return string(ts.CustomFont) } - return sty.FontFamily(&rich.DefaultSettings) + return sty.FontFamily(&rich.Settings) } func (fn *Font) FamilyString(tsty *Style) string { diff --git a/text/textcore/layout.go b/text/textcore/layout.go index ce82964508..b17e08d48b 100644 --- a/text/textcore/layout.go +++ b/text/textcore/layout.go @@ -44,7 +44,7 @@ func (ed *Base) styleSizes() { if sh != nil { lht := ed.Styles.LineHeightDots() tx := rich.NewText(sty, []rune{'M'}) - r := sh.Shape(tx, tsty, &rich.DefaultSettings) + r := sh.Shape(tx, tsty) ed.charSize.X = math32.Round(r[0].Advance()) ed.charSize.Y = lht } diff --git a/text/textcore/render.go b/text/textcore/render.go index 408ae77f99..5a78460feb 100644 --- a/text/textcore/render.go +++ b/text/textcore/render.go @@ -172,7 +172,6 @@ func (ed *Base) renderLine(li, ln int, rpos math32.Vector2, vsel textpos.Region, vlr := buf.ViewLineRegionNoLock(ed.viewId, ln) vseli := vlr.Intersect(vsel, ed.linesSize.X) tx := buf.ViewMarkupLine(ed.viewId, ln) - ctx := &rich.DefaultSettings ts := ed.Lines.Settings.TabSize indent := 0 sty, tsty := ed.Styles.NewRichText() @@ -181,7 +180,7 @@ func (ed *Base) renderLine(li, ln int, rpos math32.Vector2, vsel textpos.Region, if ed.tabRender != nil { return ed.tabRender.Clone() } - lns := sh.WrapLines(stx, sty, tsty, ctx, ssz) + lns := sh.WrapLines(stx, sty, tsty, ssz) ed.tabRender = lns return lns } @@ -191,7 +190,7 @@ func (ed *Base) renderLine(li, ln int, rpos math32.Vector2, vsel textpos.Region, if rc.lns != nil && slices.Compare(rc.tx, txt) == 0 { return rc.lns } - lns := sh.WrapLines(stx, sty, tsty, ctx, ssz) + lns := sh.WrapLines(stx, sty, tsty, ssz) ed.lineRenders[li] = renderCache{tx: txt, lns: lns} return lns } @@ -318,7 +317,7 @@ func (ed *Base) renderLineNumber(pos math32.Vector2, li, ln int) { if rc.lns != nil && slices.Compare(rc.tx, tx[0]) == 0 { // captures styling lns = rc.lns } else { - lns = sh.WrapLines(tx, sty, tsty, &rich.DefaultSettings, sz) + lns = sh.WrapLines(tx, sty, tsty, sz) ed.lineNoRenders[li] = renderCache{tx: tx[0], lns: lns} } pc.DrawText(lns, pos) diff --git a/xyz/text2d.go b/xyz/text2d.go index 4a4bd99b0b..b2db8da6ed 100644 --- a/xyz/text2d.go +++ b/xyz/text2d.go @@ -114,7 +114,7 @@ func (txt *Text2D) RenderText() { } sz := math32.Vec2(10000, 1000) // just a big size txt.richText, _ = htmltext.HTMLToRich([]byte(txt.Text), sty, nil) - txt.textRender = txt.Scene.TextShaper.WrapLines(txt.richText, sty, tsty, &rich.DefaultSettings, sz) + txt.textRender = txt.Scene.TextShaper.WrapLines(txt.richText, sty, tsty, sz) sz = txt.textRender.Bounds.Size().Ceil() if sz.X == 0 { sz.X = 10 diff --git a/yaegicore/coresymbols/cogentcore_org-core-core.go b/yaegicore/coresymbols/cogentcore_org-core-core.go index 9744d81763..00f3b5fafc 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-core.go +++ b/yaegicore/coresymbols/cogentcore_org-core-core.go @@ -21,13 +21,6 @@ import ( func init() { Symbols["cogentcore.org/core/core/core"] = map[string]reflect.Value{ // function, constant and variable definitions - "A1": reflect.ValueOf(core.A1), - "A2": reflect.ValueOf(core.A2), - "A3": reflect.ValueOf(core.A3), - "A4": reflect.ValueOf(core.A4), - "A5": reflect.ValueOf(core.A5), - "A6": reflect.ValueOf(core.A6), - "A7": reflect.ValueOf(core.A7), "AllRenderWindows": reflect.ValueOf(&core.AllRenderWindows).Elem(), "AllSettings": reflect.ValueOf(&core.AllSettings).Elem(), "AppAbout": reflect.ValueOf(&core.AppAbout).Elem(), @@ -71,8 +64,6 @@ func init() { "InspectorWindow": reflect.ValueOf(core.InspectorWindow), "LayoutPassesN": reflect.ValueOf(core.LayoutPassesN), "LayoutPassesValues": reflect.ValueOf(core.LayoutPassesValues), - "Legal": reflect.ValueOf(core.Legal), - "Letter": reflect.ValueOf(core.Letter), "ListColProperty": reflect.ValueOf(constant.MakeFromLiteral("\"ls-col\"", token.STRING, 0)), "ListRowProperty": reflect.ValueOf(constant.MakeFromLiteral("\"ls-row\"", token.STRING, 0)), "LoadAllSettings": reflect.ValueOf(core.LoadAllSettings), @@ -151,8 +142,6 @@ func init() { "NewValue": reflect.ValueOf(core.NewValue), "NewWidgetBase": reflect.ValueOf(core.NewWidgetBase), "NoSentenceCaseFor": reflect.ValueOf(&core.NoSentenceCaseFor).Elem(), - "PageSizesN": reflect.ValueOf(core.PageSizesN), - "PageSizesValues": reflect.ValueOf(core.PageSizesValues), "ProfileToggle": reflect.ValueOf(core.ProfileToggle), "RecycleDialog": reflect.ValueOf(core.RecycleDialog), "RecycleMainWindow": reflect.ValueOf(core.RecycleMainWindow), @@ -192,7 +181,6 @@ func init() { "SystemSettings": reflect.ValueOf(&core.SystemSettings).Elem(), "TabTypesN": reflect.ValueOf(core.TabTypesN), "TabTypesValues": reflect.ValueOf(core.TabTypesValues), - "Tabloid": reflect.ValueOf(core.Tabloid), "TextBodyLarge": reflect.ValueOf(core.TextBodyLarge), "TextBodyMedium": reflect.ValueOf(core.TextBodyMedium), "TextBodySmall": reflect.ValueOf(core.TextBodySmall), @@ -273,6 +261,8 @@ func init() { "Frame": reflect.ValueOf((*core.Frame)(nil)), "FuncArg": reflect.ValueOf((*core.FuncArg)(nil)), "FuncButton": reflect.ValueOf((*core.FuncButton)(nil)), + "GeomSize": reflect.ValueOf((*core.GeomSize)(nil)), + "GeomState": reflect.ValueOf((*core.GeomState)(nil)), "Handle": reflect.ValueOf((*core.Handle)(nil)), "HighlightingButton": reflect.ValueOf((*core.HighlightingButton)(nil)), "HighlightingName": reflect.ValueOf((*core.HighlightingName)(nil)), @@ -298,7 +288,6 @@ func init() { "Meter": reflect.ValueOf((*core.Meter)(nil)), "MeterTypes": reflect.ValueOf((*core.MeterTypes)(nil)), "OnBinder": reflect.ValueOf((*core.OnBinder)(nil)), - "PageSizes": reflect.ValueOf((*core.PageSizes)(nil)), "Pages": reflect.ValueOf((*core.Pages)(nil)), "SVG": reflect.ValueOf((*core.SVG)(nil)), "Scene": reflect.ValueOf((*core.Scene)(nil)), diff --git a/yaegicore/coresymbols/cogentcore_org-core-text-rich.go b/yaegicore/coresymbols/cogentcore_org-core-text-rich.go index 0e6aacb400..04309430f2 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-text-rich.go +++ b/yaegicore/coresymbols/cogentcore_org-core-text-rich.go @@ -26,7 +26,6 @@ func init() { "DecorationsN": reflect.ValueOf(rich.DecorationsN), "DecorationsValues": reflect.ValueOf(rich.DecorationsValues), "Default": reflect.ValueOf(rich.Default), - "DefaultSettings": reflect.ValueOf(&rich.DefaultSettings).Elem(), "DirectionMask": reflect.ValueOf(constant.MakeFromLiteral("4026531840", token.INT, 0)), "DirectionStart": reflect.ValueOf(constant.MakeFromLiteral("28", token.INT, 0)), "DirectionsN": reflect.ValueOf(rich.DirectionsN), @@ -89,6 +88,7 @@ func init() { "SemiExpanded": reflect.ValueOf(rich.SemiExpanded), "Semibold": reflect.ValueOf(rich.Semibold), "Serif": reflect.ValueOf(rich.Serif), + "Settings": reflect.ValueOf(&rich.Settings).Elem(), "SlantMask": reflect.ValueOf(constant.MakeFromLiteral("2048", token.INT, 0)), "SlantNormal": reflect.ValueOf(rich.SlantNormal), "SlantStart": reflect.ValueOf(constant.MakeFromLiteral("11", token.INT, 0)), @@ -117,17 +117,17 @@ func init() { "WeightsValues": reflect.ValueOf(rich.WeightsValues), // type definitions - "Decorations": reflect.ValueOf((*rich.Decorations)(nil)), - "Directions": reflect.ValueOf((*rich.Directions)(nil)), - "Family": reflect.ValueOf((*rich.Family)(nil)), - "FontName": reflect.ValueOf((*rich.FontName)(nil)), - "Hyperlink": reflect.ValueOf((*rich.Hyperlink)(nil)), - "Settings": reflect.ValueOf((*rich.Settings)(nil)), - "Slants": reflect.ValueOf((*rich.Slants)(nil)), - "Specials": reflect.ValueOf((*rich.Specials)(nil)), - "Stretch": reflect.ValueOf((*rich.Stretch)(nil)), - "Style": reflect.ValueOf((*rich.Style)(nil)), - "Text": reflect.ValueOf((*rich.Text)(nil)), - "Weights": reflect.ValueOf((*rich.Weights)(nil)), + "Decorations": reflect.ValueOf((*rich.Decorations)(nil)), + "Directions": reflect.ValueOf((*rich.Directions)(nil)), + "Family": reflect.ValueOf((*rich.Family)(nil)), + "FontName": reflect.ValueOf((*rich.FontName)(nil)), + "Hyperlink": reflect.ValueOf((*rich.Hyperlink)(nil)), + "SettingsData": reflect.ValueOf((*rich.SettingsData)(nil)), + "Slants": reflect.ValueOf((*rich.Slants)(nil)), + "Specials": reflect.ValueOf((*rich.Specials)(nil)), + "Stretch": reflect.ValueOf((*rich.Stretch)(nil)), + "Style": reflect.ValueOf((*rich.Style)(nil)), + "Text": reflect.ValueOf((*rich.Text)(nil)), + "Weights": reflect.ValueOf((*rich.Weights)(nil)), } } From ac20cf12b8386de8841eda838ae07717351137f3 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 14:20:39 +0200 Subject: [PATCH 14/99] pdf: handles font setting, updating, layout looks good. --- paint/pdf/writer.go | 12 +++++++++-- text/paginate/options.go | 9 +++++++++ text/paginate/paginate.go | 37 ++++++++++++++++++++++++++++++++++ text/paginate/paginate_test.go | 5 ++++- text/paginate/render.go | 20 +++++++++++------- 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index d5ab0415d8..36237b476e 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -301,11 +301,19 @@ func standardFontName(sty *rich.Style) string { if sty.Weight > rich.Medium { name += "-Bold" if sty.Slant == rich.Italic { - name += "Oblique" + if name == "Times" { + name += "Italic" + } else { + name += "Oblique" + } } } else { if sty.Slant == rich.Italic { - name += "-Oblique" + if name == "Times" { + name += "-Italic" + } else { + name += "-Oblique" + } } } if name == "Times" { diff --git a/text/paginate/options.go b/text/paginate/options.go index 2211f5da7b..586de0d75e 100644 --- a/text/paginate/options.go +++ b/text/paginate/options.go @@ -11,6 +11,7 @@ import ( "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/paginate/pagesizes" + "cogentcore.org/core/text/rich" ) // Options has the parameters for pagination. @@ -29,6 +30,14 @@ type Options struct { // Margins specify the page margins in the size units. Margins sides.Floats `display:"inline"` + // FontFamily specifies the default font family to apply + // to all core.Text elements. + FontFamily rich.Family + + // FontSize specifies the default font size to apply + // to all core.Text elements, if non-zero. + FontSize units.Value + // Header is the header template string, with # // replaced with the page number // adds a stretch element that can be used to accomplish diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index dac5c6eafe..a1664c2c64 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -6,17 +6,22 @@ package paginate import ( "cogentcore.org/core/core" + "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/tree" ) // Paginate organizes the given input widget content into frames // that each fit within the page size specified in the options. +// See PDF for function that generates paginated PDFs suitable +// for printing: it ensures that the content layout matches +// the page sizes, for example, which is not done in this version. func Paginate(opts Options, ins ...core.Widget) []*core.Frame { if len(ins) == 0 { return nil } p := pager{opts: &opts, ins: ins} + p.optsUpdate() p.paginate() return p.outs } @@ -30,6 +35,38 @@ type pager struct { ctx units.Context } +// optsUpdate updates the option sizes based on unit context in first input. +func (p *pager) optsUpdate() { + p.opts.Update() + in0 := p.ins[0].AsWidget() + p.ctx = in0.Styles.UnitContext + p.opts.ToDots(&p.ctx) +} + +// preRender re-renders inputs with styles enforced to fit page size, +// and setting the font family and size for text elements. +func (p *pager) preRender() { + for _, in := range p.ins { + iw := core.AsWidget(in) + + iw.FinalStyler(func(s *styles.Style) { + s.Min.X.Dot(p.opts.bodyDots.X) + s.Min.Y.Dot(p.opts.bodyDots.Y) + }) + iw.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { + if _, ok := cwb.This.(*core.Text); ok { + cwb.Styler(func(s *styles.Style) { + s.Font.Family = p.opts.FontFamily + }) + } + return true + }) + + iw.Scene.StyleTree() + iw.Scene.LayoutRenderScene() + } +} + func (p *pager) paginate() { p.opts.Update() p.ctx = p.ins[0].(core.Widget).AsWidget().Styles.UnitContext diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index a667561042..aba6f4f12d 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -14,6 +14,7 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/events" "cogentcore.org/core/styles" + "cogentcore.org/core/text/rich" ) // RunTest runs a test for given test case. @@ -27,8 +28,10 @@ func RunTest(t *testing.T, nm string, f func() *core.Body) { b.RunWindow() <-showed + opts := NewOptions() + opts.FontFamily = rich.Serif buff := bytes.Buffer{} - PDF(&buff, NewOptions(), b) + PDF(&buff, opts, b) os.Mkdir("testdata", 0777) os.WriteFile(filepath.Join("testdata", nm)+".pdf", buff.Bytes(), 0666) } diff --git a/text/paginate/render.go b/text/paginate/render.go index f5f6ac99fd..03b738dbe2 100644 --- a/text/paginate/render.go +++ b/text/paginate/render.go @@ -5,7 +5,6 @@ package paginate import ( - "fmt" "io" "cogentcore.org/core/core" @@ -17,18 +16,24 @@ import ( ) // PDF generates PDF pages from given input content using given options, -// writing to the given writer. +// writing to the given writer. It re-renders the input widgets with +// the default PDF fonts in place (Helvetica, Times, Courier), +// and the size set to the target size as configured in the options. +// This will produce accurate PDF layout. func PDF(w io.Writer, opts Options, ins ...core.Widget) { if len(ins) == 0 { return } - fmt.Println("\n\n#######################\nREDOING\n#######################") - rich.Settings.SansSerif = "Arial" - in0 := ins[0].AsWidget() - in0.Scene.StyleTree() - in0.Scene.LayoutRenderScene() + cset := rich.Settings + rich.Settings.SansSerif = "Helvetica" + rich.Settings.Serif = "Times" + rich.Settings.Monospace = "Courier" + p := pager{opts: &opts, ins: ins} + p.optsUpdate() + p.preRender() p.paginate() + sc := core.NewScene() sz := math32.Geom2DInt{} sz.Size = opts.sizeDots.ToPointCeil() @@ -52,4 +57,5 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { sc.DeleteChildren() } pdr.EndRender() + rich.Settings = cset } From 67f7b5a3b23d20326ed261ccbe8fd0aacc0ed39a Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 15:54:19 +0200 Subject: [PATCH 15/99] pdf: first-pass working on docs / content -- still many issues.. :) --- content/content.go | 23 ++++++++++++ docs/docs.go | 6 ++++ text/paginate/paginate.go | 75 ++++++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/content/content.go b/content/content.go index 7823ea66ff..d2e3dd0f40 100644 --- a/content/content.go +++ b/content/content.go @@ -15,6 +15,7 @@ import ( "io" "io/fs" "net/http" + "os" "path/filepath" "slices" "strconv" @@ -36,6 +37,8 @@ import ( "cogentcore.org/core/styles/units" "cogentcore.org/core/system" "cogentcore.org/core/text/csl" + "cogentcore.org/core/text/paginate" + "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -529,3 +532,23 @@ func (ct *Content) setStageTitle() { rw.SetStageTitle(name) } } + +// PagePDF generates a PDF of the current page, to given file path +// (directory). the page name is the file name. +func (ct *Content) PagePDF(path string) error { + if ct.currentPage == nil { + return errors.Log(errors.New("Page empty")) + } + fname := ct.currentPage.Name + ".pdf" + if path != "" { + fname = filepath.Join(path, fname) + } + f, err := os.Create(fname) + if errors.Log(err) != nil { + return err + } + opts := paginate.NewOptions() + opts.FontFamily = rich.Serif + paginate.PDF(f, opts, ct.rightFrame) + return f.Close() +} diff --git a/docs/docs.go b/docs/docs.go index 45ff203216..9191051a22 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -88,6 +88,12 @@ func main() { ctx.LinkButton(w, "https://github.com/sponsors/cogentcore") w.SetText("Sponsor").SetIcon(icons.Favorite) }) + tree.Add(p, func(w *core.Button) { + w.SetText("PDF").SetIcon(icons.PictureAsPdf) + w.OnClick(func(e events.Event) { + ct.PagePDF("") + }) + }) }) }) diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index a1664c2c64..f435bc8a3b 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -5,6 +5,7 @@ package paginate import ( + "cogentcore.org/core/base/stack" "cogentcore.org/core/core" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" @@ -74,38 +75,70 @@ func (p *pager) paginate() { widg := core.AsWidget ii := 0 - ci := 0 - cIn := widg(p.ins[ii]) - cw := widg(cIn.Child(ci)) + type posn struct { + w core.Widget + i int + } + + pars := stack.Stack[*posn]{} + pars.Push(&posn{p.ins[ii], 0}) atEnd := false - gap := cIn.Styles.Gap.Dots().Floor() + gap := p.ins[0].AsWidget().Styles.Gap.Dots().Floor() + + next := func() { + start: + cp := pars.Peek() + cp.i++ + if cp.i >= cp.w.AsWidget().NumChildren() { + pars.Pop() + if len(pars) == 0 { + ii++ + if ii >= len(p.ins) { + atEnd = true + return + } + pars.Push(&posn{p.ins[ii], 0}) + return + } else { + goto start + } + } + } + + maxY := p.opts.bodyDots.Y for { // find height ht := float32(0) var ws []core.Widget for { - if cw == nil { - atEnd = true - break + cp := pars.Peek() + cpw := cp.w.AsWidget() + if cp.i >= cpw.NumChildren() { + next() + if atEnd { + break + } + continue } - ht += cw.Geom.Size.Actual.Total.Y - if ht >= p.opts.bodyDots.Y { + cw := widg(cpw.Child(cp.i)) + sz := cw.Geom.Size.Actual.Total.Y + if ht+sz > maxY { + if fr, ok := cw.This.(*core.Frame); ok { + pars.Push(&posn{fr.This.(core.Widget), 0}) + continue + } + if len(ws) == 0 { + ws = append(ws, cw.This.(core.Widget)) + } + next() break } - ht += gap.Y + ht += sz + gap.Y ws = append(ws, cw.This.(core.Widget)) - ci++ - if ci >= cIn.NumChildren() { - ci = 0 - ii++ - if ii >= len(p.ins) { - atEnd = true - break - } - cIn = widg(p.ins[ii]) - gap = cIn.Styles.Gap.Dots().Floor() // todo: need to track this per parent input + next() + if atEnd { + break } - cw = widg(cIn.Child(ci)) } // todo: rearrange elements to put text at bottom and non-text at top From 8a83173b3c422960ae81cac7c582de4ea6fc9366 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 3 Oct 2025 16:59:12 +0200 Subject: [PATCH 16/99] pdf: working solidly on docs! --- text/paginate/paginate.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index f435bc8a3b..bf8b42ba30 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -106,10 +106,11 @@ func (p *pager) paginate() { } maxY := p.opts.bodyDots.Y + // per page, list of widgets -- must accumulate all, to not change structure + var ws [][]core.Widget for { - // find height + var cws []core.Widget ht := float32(0) - var ws []core.Widget for { cp := pars.Peek() cpw := cp.w.AsWidget() @@ -127,29 +128,32 @@ func (p *pager) paginate() { pars.Push(&posn{fr.This.(core.Widget), 0}) continue } - if len(ws) == 0 { - ws = append(ws, cw.This.(core.Widget)) + if len(cws) == 0 { + cws = append(cws, cw.This.(core.Widget)) + next() } - next() break } ht += sz + gap.Y - ws = append(ws, cw.This.(core.Widget)) + cws = append(cws, cw.This.(core.Widget)) next() if atEnd { break } } - // todo: rearrange elements to put text at bottom and non-text at top + ws = append(ws, cws) + if atEnd { + break + } + } + for _, cws := range ws { + // todo: rearrange elements to put text at bottom and non-text at top // now transfer over to frame page, body := p.newPage(gap) - for _, w := range ws { + for _, w := range cws { tree.MoveToParent(w, body) } p.outs = append(p.outs, page) - if atEnd { - break - } } } From 09b509704d0f5edb32c5cc19f76f30e4786e8a1d Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sat, 4 Oct 2025 10:18:48 +0200 Subject: [PATCH 17/99] pdf: fix text rotation -- changing to top-left inevitably changes rotation angle --- content/buttons.go | 6 ++++++ docs/docs.go | 6 ------ paint/paint_test.go | 2 +- paint/pdf/page.go | 33 +++++++++++++++++++++++++++++---- paint/pdf/pdf.go | 19 +++++++++++++++++++ paint/pdf/pdf_test.go | 9 ++++----- paint/pdf/text.go | 32 ++++++++++++++++---------------- paint/text_test.go | 8 ++++---- text/paginate/render.go | 9 +++------ 9 files changed, 82 insertions(+), 42 deletions(-) diff --git a/content/buttons.go b/content/buttons.go index 1101c453e9..420ae90234 100644 --- a/content/buttons.go +++ b/content/buttons.go @@ -74,6 +74,12 @@ func (ct *Content) MakeToolbar(p *tree.Plan) { ct.Scene.MenuSearchDialog("Search", "Search "+core.TheApp.Name()) }) }) + tree.Add(p, func(w *core.Button) { + w.SetText("PDF").SetIcon(icons.PictureAsPdf) + w.OnClick(func(e events.Event) { + ct.PagePDF("") + }) + }) } func (ct *Content) MenuSearch(items *[]core.ChooserItem) { diff --git a/docs/docs.go b/docs/docs.go index 9191051a22..45ff203216 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -88,12 +88,6 @@ func main() { ctx.LinkButton(w, "https://github.com/sponsors/cogentcore") w.SetText("Sponsor").SetIcon(icons.Favorite) }) - tree.Add(p, func(w *core.Button) { - w.SetText("PDF").SetIcon(icons.PictureAsPdf) - w.OnClick(func(e events.Event) { - ct.PagePDF("") - }) - }) }) }) diff --git a/paint/paint_test.go b/paint/paint_test.go index 9b2c3e4246..869bf9a81e 100644 --- a/paint/paint_test.go +++ b/paint/paint_test.go @@ -110,7 +110,7 @@ func TestRender(t *testing.T) { tx, err := htmltext.HTMLToRich([]byte("This is HTML formatted text"), fsty, nil) assert.NoError(t, err) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, math32.Vec2(100, 60)) + lns := txtSh.WrapLines(tx, fsty, tsty, math32.Vec2(100, 60)) // if tsz.X != 100 || tsz.Y != 60 { // t.Errorf("unexpected text size: %v", tsz) // } diff --git a/paint/pdf/page.go b/paint/pdf/page.go index 2b148412c3..1c20019400 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -76,6 +76,30 @@ func (w *pdfPage) SetTransform(m math32.Matrix2) { fmt.Fprintf(w, " %s cm", mat2(m)) } +// PushStack adds a graphics stack push (q) +func (w *pdfPage) PushStack() { + fmt.Fprintf(w, " q") +} + +// PushTransform adds a graphics stack push (q) and then +// cm to set the current matrix transform (CMT). +func (w *pdfPage) PushTransform(m math32.Matrix2) { + rot := m.ExtractRot() + m2 := m + if rot != 0 { + m2 = m.Mul(math32.Rotate2D(-2 * rot)) + } + fmt.Fprintf(w, " q %s cm", mat2(m2)) +} + +// PopStack adds a graphics stack pop (Q) which must +// be paired with a corresponding Push (q). Resets the +func (w *pdfPage) PopStack() { + w.style.Fill.Color = nil // reset + w.style.Stroke.Color = nil // reset + fmt.Fprintf(w, " Q") +} + // AddAnnotation adds an annotation. func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { annot := pdfDict{ @@ -297,15 +321,15 @@ func (w *pdfPage) SetTextCharSpace(space float32) error { return nil } -// StartTextObject starts a text object, initializing the global +// StartTextObject starts a text object, adding to the graphics // CTM transform matrix as given by the arg, and setting an inverting // text transform, so text is rendered upright. func (w *pdfPage) StartTextObject(m math32.Matrix2) error { if w.inTextObject { return errors.Log(errors.New("pdfWriter: already in text object")) } - // set the global graphics transform to m first - fmt.Fprintf(w, " q %s cm", mat2(m)) + // set the graphics transform to m first + w.PushTransform(m) fmt.Fprintf(w, " BT") // then apply an inversion text matrix tm := math32.Scale2D(1, -1) @@ -320,7 +344,8 @@ func (w *pdfPage) EndTextObject() error { if !w.inTextObject { return errors.Log(errors.New("pdfWriter: must be in text object")) } - fmt.Fprintf(w, " ET Q") + fmt.Fprintf(w, " ET") + w.PopStack() w.inTextObject = false return nil } diff --git a/paint/pdf/pdf.go b/paint/pdf/pdf.go index 2554ef429f..631902be5f 100644 --- a/paint/pdf/pdf.go +++ b/paint/pdf/pdf.go @@ -15,8 +15,27 @@ import ( "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" ) +// UseStandardFonts sets the [rich.Settings] default fonts to the +// corresponding PDF defaults, so that text layout works correctly +// for the PDF rendering. The current settings are returned, +// and should be passed to [RestorePreviousFonts] when done. +func UseStandardFonts() rich.SettingsData { + prev := rich.Settings + rich.Settings.SansSerif = "Helvetica" + rich.Settings.Serif = "Times" + rich.Settings.Monospace = "Courier" + return prev +} + +// RestorePreviousFonts sets the [rich.Settings] default fonts +// to those returned from [UseStandardFonts] +func RestorePreviousFonts(s rich.SettingsData) { + rich.Settings = s +} + // type Options struct { // Compress bool // SubsetFonts bool diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 4f82a518ab..8b82115bbe 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -19,7 +19,6 @@ import ( "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/htmltext" - "cogentcore.org/core/text/rich" "cogentcore.org/core/text/shaped" _ "cogentcore.org/core/text/shaped/shapers" "github.com/alecthomas/assert/v2" @@ -54,9 +53,8 @@ func TestPath(t *testing.T) { func TestText(t *testing.T) { RunTest(t, "text", 300, 300, func(pd *PDF, sty *styles.Paint) { + prv := UseStandardFonts() sh := shaped.NewShaper() - rts := &rich.Settings{} - rts.Defaults() src := "PDF can put HTML
formatted Text where you want" rsty := &sty.Font @@ -65,11 +63,12 @@ func TestText(t *testing.T) { tx, err := htmltext.HTMLToRich([]byte(src), rsty, nil) // fmt.Println(tx) assert.NoError(t, err) - lns := sh.WrapLines(tx, rsty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, rsty, tsty, math32.Vec2(250, 250)) // m := math32.Identity2() - m := math32.Rotate2D(math32.DegToRad(-15)) + m := math32.Rotate2D(math32.DegToRad(15)) pd.Text(sty, m, math32.Vec2(20, 20), lns) + RestorePreviousFonts(prv) }) } diff --git a/paint/pdf/text.go b/paint/pdf/text.go index 9eb8da49f3..3e6052ea56 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -25,7 +25,7 @@ import ( // (the translation component specifies the starting offset) func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, lns *shaped.Lines) { mt := m.Mul(math32.Translate2D(pos.X, pos.Y)) - r.w.StartTextObject(mt) + r.w.PushTransform(mt) off := lns.Offset clr := colors.Uniform(lns.Color) runes := lns.Source.Join() @@ -33,7 +33,7 @@ func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, ln ln := &lns.Lines[li] r.textLine(style, m, ln, lns, runes, clr, off) } - r.w.EndTextObject() + r.w.PopStack() } // TextLine rasterizes the given shaped.Line. @@ -66,6 +66,7 @@ func (r *PDF) textRegionFill(m math32.Matrix2, run *shapedgt.Run, off math32.Vec if fill == nil { return } + idm := math32.Identity2() for _, sel := range ranges { rsel := sel.Intersect(run.Runes()) if rsel.Len() == 0 { @@ -75,17 +76,19 @@ func (r *PDF) textRegionFill(m math32.Matrix2, run *shapedgt.Run, off math32.Vec li := run.LastGlyphAt(rsel.End - 1) if fi >= 0 && li >= fi { sbb := run.GlyphRegionBounds(fi, li).Canon() - r.FillBox(m, sbb.Translate(off), fill) + r.FillBox(idm, sbb.Translate(off), fill) } } } // textRunRegions draws region fills for given run. func (r *PDF) textRunRegions(m math32.Matrix2, run *shapedgt.Run, ln *shaped.Line, lns *shaped.Lines, off math32.Vector2) { + idm := math32.Identity2() + // dir := run.Direction rbb := run.MaxBounds.Translate(off) if run.Background != nil { - r.FillBox(m, rbb, run.Background) + r.FillBox(idm, rbb, run.Background) } r.textRegionFill(m, run, off, lns.SelectionColor, ln.Selections) r.textRegionFill(m, run, off, lns.HighlightColor, ln.Highlights) @@ -105,13 +108,11 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, fsz := math32.FromFixed(run.Size) lineW := max(fsz/16, 1) // 1 at 16, bigger if biggerr if run.Math.Path != nil { - mm := m - mm.X0 += off.X - mm.Y0 += off.Y - r.Path(*run.Math.Path, style, mm) + r.Path(*run.Math.Path, style, math32.Translate2D(off.X, off.Y)) return } + idm := math32.Identity2() if run.Decoration.HasFlag(rich.Underline) || run.Decoration.HasFlag(rich.DottedUnderline) { dash := []float32{2, 2} if run.Decoration.HasFlag(rich.Underline) { @@ -120,29 +121,28 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, if run.Direction.IsVertical() { } else { dec := off.Y + 3 - r.strokeTextLine(m, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, dash) + r.strokeTextLine(idm, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, dash) } } if run.Decoration.HasFlag(rich.Overline) { if run.Direction.IsVertical() { } else { dec := off.Y - 0.7*rbb.Size().Y - r.strokeTextLine(m, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, nil) + r.strokeTextLine(idm, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, nil) } } + r.w.StartTextObject(math32.Translate2D(off.X, off.Y)) r.setTextStyle(&run.Font, style, fill, run.StrokeColor, math32.FromFixed(run.Size), lns.LineHeight) - - raw := runes[region.Start:region.End] - sraw := string(raw) - r.w.SetTextPosition(off) - r.w.WriteText(sraw) + raw := string(runes[region.Start:region.End]) + r.w.WriteText(raw) + r.w.EndTextObject() if run.Decoration.HasFlag(rich.LineThrough) { if run.Direction.IsVertical() { } else { dec := off.Y - 0.2*rbb.Size().Y - r.strokeTextLine(m, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, nil) + r.strokeTextLine(idm, math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, nil) } } } diff --git a/paint/text_test.go b/paint/text_test.go index 7fce7f4ccf..0e18c679bd 100644 --- a/paint/text_test.go +++ b/paint/text_test.go @@ -44,7 +44,7 @@ func TestTextAscii(t *testing.T) { y := float32(5) for _, ts := range lines { tx := rich.NewText(fsty, []rune(ts)) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, sizef) pos := math32.Vector2{5, y} pc.DrawText(lns, pos) y += 20 @@ -66,7 +66,7 @@ func TestTextMarkup(t *testing.T) { tx, err := htmltext.HTMLToRich([]byte("This is HTML formatted text with underline and strikethrough"), fsty, nil) assert.NoError(t, err) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, sizef) lns.SelectRegion(textpos.Range{Start: 5, End: 20}) // if tsz.X != 100 || tsz.Y != 40 { // t.Errorf("unexpected text size: %v", tsz) @@ -104,7 +104,7 @@ func TestTextLines(t *testing.T) { tx.AddSpan(&du, []rune("Dotted Underline")).AddSpan(fsty, []rune(" and ")).AddSpan(&uu, []rune("Underline")) tx.AddSpan(fsty, []rune(" and ")).AddSpan(&ol, []rune("Overline")) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, sizef) pos := math32.Vector2{10, 10} // pc.Paint.Transform = math32.Rotate2DAround(math32.DegToRad(-45), pos) pc.DrawText(lns, pos) @@ -135,7 +135,7 @@ func TestTextColors(t *testing.T) { tx.AddSpan(&rd, []rune("Red")).AddSpan(fsty, []rune(" and ")).AddSpan(&bl, []rune("Blue")) tx.AddSpan(fsty, []rune(" and ")).AddSpan(&gr, []rune("Green")) - lns := txtSh.WrapLines(tx, fsty, tsty, &rich.Settings, sizef) + lns := txtSh.WrapLines(tx, fsty, tsty, sizef) pos := math32.Vector2{10, 10} // pc.Paint.Transform = math32.Rotate2DAround(math32.DegToRad(-45), pos) pc.DrawText(lns, pos) diff --git a/text/paginate/render.go b/text/paginate/render.go index 03b738dbe2..a98f5c98e0 100644 --- a/text/paginate/render.go +++ b/text/paginate/render.go @@ -10,8 +10,8 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/math32" "cogentcore.org/core/paint" + "cogentcore.org/core/paint/pdf" "cogentcore.org/core/paint/renderers/pdfrender" - "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -24,10 +24,7 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { if len(ins) == 0 { return } - cset := rich.Settings - rich.Settings.SansSerif = "Helvetica" - rich.Settings.Serif = "Times" - rich.Settings.Monospace = "Courier" + cset := pdf.UseStandardFonts() p := pager{opts: &opts, ins: ins} p.optsUpdate() @@ -57,5 +54,5 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { sc.DeleteChildren() } pdr.EndRender() - rich.Settings = cset + pdf.RestorePreviousFonts(cset) } From a7987eb273c77c4120c6ce6dca7b96488c431bf7 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 5 Oct 2025 11:35:53 +0200 Subject: [PATCH 18/99] pdf: runners as functions, working well --- content/content.go | 14 ++++++- paint/pdf/page.go | 1 + paint/pdf/writer.go | 2 +- text/paginate/options.go | 25 ++++++----- text/paginate/page.go | 55 ++++++++++++++---------- text/paginate/paginate.go | 8 ++-- text/paginate/paginate_test.go | 1 + text/paginate/runners.go | 76 ++++++++++++++++++++++++++++++++++ 8 files changed, 144 insertions(+), 38 deletions(-) create mode 100644 text/paginate/runners.go diff --git a/content/content.go b/content/content.go index d2e3dd0f40..3ffb99e27a 100644 --- a/content/content.go +++ b/content/content.go @@ -194,7 +194,7 @@ func (ct *Content) Init() { switch x := w.(type) { case *core.Text: x.Styler(func(s *styles.Style) { - s.Max.X.Ch(120) + s.Max.X.In(8) // doesn't constrain PDF render }) case *core.Image: x.Styler(func(s *styles.Style) { @@ -375,6 +375,12 @@ func (ct *Content) addHistory(pg *bcontent.Page) { ct.saveWebURL() } +// reloadPage reloads the current page +func (ct *Content) reloadPage() { + ct.renderedPage = nil + ct.Update() +} + // loadPage loads the current page content into the given frame if it is not already loaded. func (ct *Content) loadPage(w *core.Frame) error { if ct.renderedPage == ct.currentPage { @@ -549,6 +555,10 @@ func (ct *Content) PagePDF(path string) error { } opts := paginate.NewOptions() opts.FontFamily = rich.Serif + opts.Header = paginate.HeaderLeftPageNumber(ct.currentPage.Name) + opts.Footer = nil paginate.PDF(f, opts, ct.rightFrame) - return f.Close() + err = f.Close() + ct.reloadPage() + return err } diff --git a/paint/pdf/page.go b/paint/pdf/page.go index 1c20019400..5018c216fc 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -32,6 +32,7 @@ type pdfPage struct { annots pdfArray graphicsStates map[float32]pdfName + // todo: make this a stack of render.Context elements, push and pop style styles.Paint inTextObject bool textPosition math32.Vector2 diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 36237b476e..8cdca95dac 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -68,7 +68,7 @@ func newPDFWriter(writer io.Writer, un *units.Context) *pdfWriter { // fontsH: map[*text.Font]pdfRef{}, // fontsV: map[*text.Font]pdfRef{}, images: map[image.Image]pdfRef{}, - compress: false, + compress: true, subset: true, } w.layerInit() diff --git a/text/paginate/options.go b/text/paginate/options.go index 586de0d75e..e3530ee0bf 100644 --- a/text/paginate/options.go +++ b/text/paginate/options.go @@ -7,6 +7,7 @@ package paginate import ( + "cogentcore.org/core/core" "cogentcore.org/core/math32" "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" @@ -38,17 +39,19 @@ type Options struct { // to all core.Text elements, if non-zero. FontSize units.Value - // Header is the header template string, with # - // replaced with the page number - // adds a stretch element that can be used to accomplish - // justification: at start = right justify, at start and end = center - Header string + // Title generates the title contents for the first page, + // into the given page body frame. + Title func(frame *core.Frame, opts *Options) - // Footer is the footer template string, with # - // replaced with the page number. - // adds a stretch element that can be used to accomplish - // justification: at start = right justify, at start and end = center - Footer string + // Header generates the header contents for the page, into the given + // frame that represents the entire top margin. + // See examples in runners.go + Header func(frame *core.Frame, opts *Options, pageNo int) + + // Footer generates the footer contents for the page, into the given + // frame that represents the entire top margin. + // See examples in runners.go + Footer func(frame *core.Frame, opts *Options, pageNo int) sizeDots math32.Vector2 // total size in dots bodyDots math32.Vector2 // body (content) size in dots @@ -65,7 +68,7 @@ func (o *Options) Defaults() { // todo: make this contingent on localization somehow! o.PageSize = pagesizes.A4 o.Margins.Set(25) // basically one inch - o.Footer = "#" + o.Footer = CenteredPageNumber o.Update() } diff --git a/text/paginate/page.go b/text/paginate/page.go index 992e0a0e93..d1ea3b3350 100644 --- a/text/paginate/page.go +++ b/text/paginate/page.go @@ -20,38 +20,40 @@ func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { s.Max.X.Dot(x) s.Max.Y.Dot(y) } - pn := fmt.Sprintf("page-%d", len(p.outs)+1) + + curPage := len(p.outs) + 1 + pn := fmt.Sprintf("page-%d", curPage) + page = core.NewFrame() page.SetName(pn) page.Styler(func(s *styles.Style) { - s.Direction = styles.Column + s.Direction = styles.Row styMinMax(s, p.opts.sizeDots.X, p.opts.sizeDots.Y) }) - hdr := core.NewFrame(page) - hdr.SetName("header") - hdr.Styler(func(s *styles.Style) { + lmar := core.NewFrame(page) + lmar.SetName("left-margin") + lmar.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.sizeDots.X, p.opts.margDots.Top) - }) - bodRow := core.NewFrame(page) - bodRow.SetName("body-row") - bodRow.Styler(func(s *styles.Style) { - s.Direction = styles.Row - styMinMax(s, p.opts.sizeDots.X, p.opts.bodyDots.Y) + styMinMax(s, p.opts.margDots.Left, p.opts.sizeDots.Y) }) - ftr := core.NewFrame(page) - ftr.SetName("footer") - ftr.Styler(func(s *styles.Style) { + bfr := core.NewFrame(page) + bfr.SetName("body-frame") + bfr.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.sizeDots.X, p.opts.margDots.Bottom) + styMinMax(s, p.opts.bodyDots.X, p.opts.sizeDots.Y) }) - lmar := core.NewFrame(bodRow) - lmar.SetName("left-margin") - lmar.Styler(func(s *styles.Style) { + + hdr := core.NewFrame(bfr) + hdr.SetName("header") + hdr.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.margDots.Left, p.opts.bodyDots.Y) + styMinMax(s, p.opts.bodyDots.X, p.opts.margDots.Top) }) - body = core.NewFrame(bodRow) + if p.opts.Header != nil { + p.opts.Header(hdr, p.opts, curPage) + } + + body = core.NewFrame(bfr) body.SetName("body") body.Styler(func(s *styles.Style) { s.Direction = styles.Column @@ -59,5 +61,16 @@ func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { s.Gap.X.Dot(gap.X) s.Gap.Y.Dot(gap.Y) }) + + ftr := core.NewFrame(bfr) + ftr.SetName("footer") + ftr.Styler(func(s *styles.Style) { + s.Direction = styles.Column + styMinMax(s, p.opts.bodyDots.X, p.opts.margDots.Bottom) + }) + if p.opts.Footer != nil { + p.opts.Footer(ftr, p.opts, curPage) + } + return } diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index bf8b42ba30..00d459810b 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -56,9 +56,11 @@ func (p *pager) preRender() { }) iw.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { if _, ok := cwb.This.(*core.Text); ok { - cwb.Styler(func(s *styles.Style) { - s.Font.Family = p.opts.FontFamily - }) + if _, ok := cwb.Parent.(*core.Frame); ok { // not inside buttons etc + cwb.Styler(func(s *styles.Style) { + s.Font.Family = p.opts.FontFamily + }) + } } return true }) diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index aba6f4f12d..dfce9a9291 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -30,6 +30,7 @@ func RunTest(t *testing.T, nm string, f func() *core.Body) { opts := NewOptions() opts.FontFamily = rich.Serif + opts.Header = HeaderLeftPageNumber("This is a test header") buff := bytes.Buffer{} PDF(&buff, opts, b) os.Mkdir("testdata", 0777) diff --git a/text/paginate/runners.go b/text/paginate/runners.go new file mode 100644 index 0000000000..f82776ae6f --- /dev/null +++ b/text/paginate/runners.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paginate + +import ( + "strconv" + + "cogentcore.org/core/core" + "cogentcore.org/core/styles" + "cogentcore.org/core/text/rich" +) + +// CenteredPageNumber generates a page number cenetered in the frame +// with a 1.5em space above it. +func CenteredPageNumber(frame *core.Frame, opts *Options, pageNo int) { + core.NewSpace(frame).Styler(func(s *styles.Style) { // space before + s.Min.Y.Em(1.5) + s.Grow.Set(1, 0) + }) + fr := core.NewFrame(frame) + fr.Styler(func(s *styles.Style) { + s.Direction = styles.Row + s.Grow.Set(1, 0) + s.Justify.Content = styles.Center + }) + core.NewText(fr).SetText(strconv.Itoa(pageNo)) +} + +// CenteredPageNumberNoFirst generates a page number cenetered in the frame +// with a 1.5em space above it. Skips the first one. +func CenteredPageNumberNoFirst(frame *core.Frame, opts *Options, pageNo int) { + if pageNo == 1 { + return + } + core.NewSpace(frame).Styler(func(s *styles.Style) { // space before + s.Min.Y.Em(1.5) + s.Grow.Set(1, 0) + }) + fr := core.NewFrame(frame) + fr.Styler(func(s *styles.Style) { + s.Direction = styles.Row + s.Grow.Set(1, 0) + s.Justify.Content = styles.Center + }) + core.NewText(fr).SetText(strconv.Itoa(pageNo)) +} + +// HeaderLeftPageNumber adds a running header with page number on the right. +func HeaderLeftPageNumber(header string) func(frame *core.Frame, opts *Options, pageNo int) { + return func(frame *core.Frame, opts *Options, pageNo int) { + core.NewStretch(frame) + fr := core.NewFrame(frame) + fr.Styler(func(s *styles.Style) { + s.Direction = styles.Row + s.Grow.Set(1, 0) + }) + core.NewText(fr).SetText(header).Styler(func(s *styles.Style) { + s.SetTextWrap(false) + s.Font.Family = opts.FontFamily + s.Font.Slant = rich.Italic + s.Font.Size.Pt(11) + }) + core.NewStretch(fr) + core.NewText(fr).SetText(strconv.Itoa(pageNo)).Styler(func(s *styles.Style) { + s.SetTextWrap(false) + s.Font.Family = opts.FontFamily + s.Font.Size.Pt(11) + }) + core.NewSpace(frame).Styler(func(s *styles.Style) { // space after + s.Min.Y.Em(3) + s.Grow.Set(1, 0) + }) + } +} From 5338325a215577ac33bc16323bf75f1f5bd14054 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 5 Oct 2025 12:40:01 +0200 Subject: [PATCH 19/99] pdf: context stack tracks pdf state better -- svg test not working well at all -- offsets are off --- paint/pdf/context.go | 71 ++++++++++++++++++++++++++++++++++++++++++++ paint/pdf/layer.go | 9 +++--- paint/pdf/page.go | 51 ++++++++----------------------- paint/pdf/pdf.go | 2 ++ paint/pdf/writer.go | 11 +++---- svg/svg_test.go | 25 ++++++++++++++-- 6 files changed, 117 insertions(+), 52 deletions(-) create mode 100644 paint/pdf/context.go diff --git a/paint/pdf/context.go b/paint/pdf/context.go new file mode 100644 index 0000000000..b48d152c0a --- /dev/null +++ b/paint/pdf/context.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pdf + +import ( + "fmt" + + "cogentcore.org/core/math32" + "cogentcore.org/core/styles" +) + +// context holds the graphics state context to track the corresponding +// PDF state to optimize setting of styles. +type context struct { + + // Style is current style: copied from parent context initially. + Style styles.Paint + + // Transform is the current transform that has been set. + // it is not accumulated. + Transform math32.Matrix2 +} + +func newContext(sty *styles.Paint, ctm math32.Matrix2) *context { + c := &context{} + c.Style = *sty + c.Transform = ctm + return c +} + +// PushStack adds a graphics stack push (q), which must +// be paired with a corresponding Pop (Q). +func (w *pdfPage) PushStack() { + ctx := w.stack.Peek() + fmt.Fprintf(w, " q") + w.stack.Push(newContext(&ctx.Style, ctx.Transform)) +} + +// PopStack adds a graphics stack pop (Q) which must +// be paired with a corresponding Push (q). +func (w *pdfPage) PopStack() { + fmt.Fprintf(w, " Q") + w.stack.Pop() +} + +// SetTransform adds a cm to set the current matrix transform (CMT). +func (w *pdfPage) SetTransform(m math32.Matrix2) { + rot := m.ExtractRot() + m2 := m + if rot != 0 { + m2 = m.Mul(math32.Rotate2D(-2 * rot)) + } + fmt.Fprintf(w, " %s cm", mat2(m2)) + ctx := w.stack.Peek() + ctx.Transform = ctx.Transform.Mul(m2) +} + +// PushTransform adds a graphics stack push (q) and then +// cm to set the current matrix transform (CMT). +func (w *pdfPage) PushTransform(m math32.Matrix2) { + w.PushStack() + w.SetTransform(m) +} + +// style() returns the currently active style +func (w *pdfPage) style() *styles.Paint { + ctx := w.stack.Peek() + return &ctx.Style +} diff --git a/paint/pdf/layer.go b/paint/pdf/layer.go index 79595c05c1..e2e7879ee1 100644 --- a/paint/pdf/layer.go +++ b/paint/pdf/layer.go @@ -44,21 +44,20 @@ func (w *pdfWriter) AddLayer(name string, visible bool) (layerID int) { // BeginLayer is called to begin adding content to the specified layer. All // content added to the page between a call to BeginLayer and a call to // EndLayer is added to the layer specified by id. See AddLayer for more -// details. The graphics state is also pushed onto the stack +// details. func (w *pdfWriter) BeginLayer(id int) { w.EndLayer() if id >= 0 && id < len(w.layers.list) { - w.write("/OC /OC%d BDC q", id) + w.write("/OC /OC%d BDC", id) w.layers.currentLayer = id } } // EndLayer is called to stop adding content to the currently active layer. -// See BeginLayer for more details. The graphics state is also popped from -// the stack. +// See BeginLayer for more details. func (w *pdfWriter) EndLayer() { if w.layers.currentLayer >= 0 { - w.write("EMC Q") + w.write(" EMC") w.layers.currentLayer = -1 } } diff --git a/paint/pdf/page.go b/paint/pdf/page.go index 5018c216fc..daec74ebbe 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -14,6 +14,7 @@ import ( "log/slog" "cogentcore.org/core/base/errors" + "cogentcore.org/core/base/stack" "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" @@ -32,8 +33,7 @@ type pdfPage struct { annots pdfArray graphicsStates map[float32]pdfName - // todo: make this a stack of render.Context elements, push and pop - style styles.Paint + stack stack.Stack[*context] inTextObject bool textPosition math32.Vector2 textCharSpace float32 @@ -72,35 +72,6 @@ func (w *pdfPage) writePage(parent pdfRef) pdfRef { return w.pdf.writeObject(page) } -// SetTransform adds a cm to set the current matrix transform (CMT). -func (w *pdfPage) SetTransform(m math32.Matrix2) { - fmt.Fprintf(w, " %s cm", mat2(m)) -} - -// PushStack adds a graphics stack push (q) -func (w *pdfPage) PushStack() { - fmt.Fprintf(w, " q") -} - -// PushTransform adds a graphics stack push (q) and then -// cm to set the current matrix transform (CMT). -func (w *pdfPage) PushTransform(m math32.Matrix2) { - rot := m.ExtractRot() - m2 := m - if rot != 0 { - m2 = m.Mul(math32.Rotate2D(-2 * rot)) - } - fmt.Fprintf(w, " q %s cm", mat2(m2)) -} - -// PopStack adds a graphics stack pop (Q) which must -// be paired with a corresponding Push (q). Resets the -func (w *pdfPage) PopStack() { - w.style.Fill.Color = nil // reset - w.style.Stroke.Color = nil // reset - fmt.Fprintf(w, " Q") -} - // AddAnnotation adds an annotation. func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { annot := pdfDict{ @@ -119,10 +90,11 @@ func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { // SetFill sets the fill style values where different from current. func (w *pdfPage) SetFill(fill *styles.Fill) { - if w.style.Fill.Color != fill.Color || w.style.Fill.Opacity != fill.Opacity { + csty := w.style() + if csty.Fill.Color != fill.Color || csty.Fill.Opacity != fill.Opacity { w.SetFillColor(fill) } - w.style.Fill = *fill + csty.Fill = *fill } // SetAlpha sets the transparency value. @@ -153,26 +125,27 @@ func (w *pdfPage) SetFillColor(fill *styles.Fill) { // SetStroke sets the stroke style values where different from current. func (w *pdfPage) SetStroke(stroke *styles.Stroke) { - if w.style.Stroke.Color != stroke.Color || w.style.Stroke.Opacity != stroke.Opacity { + csty := w.style() + if csty.Stroke.Color != stroke.Color || csty.Stroke.Opacity != stroke.Opacity { w.SetStrokeColor(stroke) } - if w.style.Stroke.Width.Dots != stroke.Width.Dots { + if csty.Stroke.Width.Dots != stroke.Width.Dots { w.SetStrokeWidth(stroke.Width.Dots) } - if w.style.Stroke.Cap != stroke.Cap { + if csty.Stroke.Cap != stroke.Cap { w.SetStrokeCap(stroke.Cap) } - if w.style.Stroke.Join != stroke.Join || (w.style.Stroke.Join == ppath.JoinMiter && w.style.Stroke.MiterLimit != stroke.MiterLimit) { + if csty.Stroke.Join != stroke.Join || (csty.Stroke.Join == ppath.JoinMiter && csty.Stroke.MiterLimit != stroke.MiterLimit) { w.SetStrokeJoin(stroke.Join, stroke.MiterLimit) } if len(stroke.Dashes) > 0 { // always do w.SetDashes(stroke.DashOffset, stroke.Dashes) } else { - if len(w.style.Stroke.Dashes) > 0 { + if len(csty.Stroke.Dashes) > 0 { w.SetDashes(0, nil) } } - w.style.Stroke = *stroke + csty.Stroke = *stroke } // SetStrokeColor sets the stroking color (image). diff --git a/paint/pdf/pdf.go b/paint/pdf/pdf.go index 631902be5f..6e49389ccc 100644 --- a/paint/pdf/pdf.go +++ b/paint/pdf/pdf.go @@ -129,6 +129,7 @@ func (r *PDF) AddLayer(name string, visible bool) (layerID int) { // matrix, if not identity. func (r *PDF) BeginLayer(id int, m math32.Matrix2) { r.w.pdf.BeginLayer(id) + r.w.PushStack() if !m.IsIdentity() { r.w.SetTransform(m) } @@ -137,6 +138,7 @@ func (r *PDF) BeginLayer(id int, m math32.Matrix2) { // EndLayer is called to stop adding content to the currently active layer. See // BeginLayer for more details. func (r *PDF) EndLayer() { + r.w.PopStack() r.w.pdf.EndLayer() } diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 8cdca95dac..d41f65aa51 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -22,6 +22,7 @@ import ( "cogentcore.org/core/math32" "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" "cogentcore.org/core/text/text" @@ -487,18 +488,18 @@ func (w *pdfWriter) NewPage(width, height float32) *pdfPage { textCharSpace: 0.0, textRenderMode: 0, } - w.page.style.Defaults() - w.page.SetTopTransform() + w.page.stack.Push(newContext(styles.NewPaint(), math32.Identity2())) + w.page.setTopTransform() return w.page } -// SetTopTransform sets the current transformation matrix so that +// setTopTransform sets the current transformation matrix so that // the top left corner is effectively at 0,0. This is set at the // start of each page, to align with standard rendering in cogent core. -func (w *pdfPage) SetTopTransform() { +func (w *pdfPage) setTopTransform() { sc := w.pdf.globalScale m := math32.Translate2D(0, w.height).Scale(sc, -sc) - fmt.Fprintf(w, " %s cm", mat2(m)) + w.SetTransform(m) } type dec float32 diff --git a/svg/svg_test.go b/svg/svg_test.go index 2c5da57d6c..cd12b30bef 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -18,21 +18,40 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/colors/cam/hct" "cogentcore.org/core/math32" + "cogentcore.org/core/paint" _ "cogentcore.org/core/paint/renderers" // installs default renderer + "cogentcore.org/core/styles/units" . "cogentcore.org/core/svg" "github.com/go-text/typesetting/font" "github.com/stretchr/testify/assert" ) func RunTest(t *testing.T, width, height int, dir, fname string) { - sv := NewSVG(math32.Vec2(float32(width), float32(height))) + size := math32.Vec2(float32(width), float32(height)) + sv := NewSVG(size) svfn := filepath.Join("testdata", dir, fname) err := sv.OpenXML(svfn) assert.NoError(t, err) - img := sv.RenderImage() - imfn := filepath.Join(dir, "png", strings.TrimSuffix(fname, ".svg")) + rend := sv.Render(nil).RenderDone() + + rd := paint.NewImageRenderer(size) + img := rd.Render(rend).Image() + bnm := strings.TrimSuffix(fname, ".svg") + imfn := filepath.Join(dir, "png", bnm) // fmt.Println(svfn, imfn) imagex.Assert(t, img, imfn) + + // todo: sizing and offset is not right -- need to install that somehow. + pddir := filepath.Join("testdata", dir, "pdf") + pdfn := filepath.Join(pddir, bnm+".pdf") + ctx := units.NewContext() + pd := paint.NewPDFRenderer(size, ctx) + pd.SetSize(units.UnitPx, size) // should be correct + // pd.SetSize(units.UnitMm, size) // to see everything + pd.Render(rend) + os.MkdirAll(pddir, 0777) + err = os.WriteFile(pdfn, pd.Source(), 0666) + assert.NoError(t, err) } func TestSVG(t *testing.T) { From 7cd80ac79b2e2c249db089953ff7a161e626549e Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 5 Oct 2025 13:50:31 +0200 Subject: [PATCH 20/99] pdf: new pagination framework with different stages -- much better. properties control pagination. just needs testing of that and floats --- text/paginate/README.md | 17 +++++++- text/paginate/extract.go | 92 +++++++++++++++++++++++++++++++++++++++ text/paginate/layout.go | 71 ++++++++++++++++++++++++++++++ text/paginate/paginate.go | 91 +------------------------------------- 4 files changed, 181 insertions(+), 90 deletions(-) create mode 100644 text/paginate/extract.go create mode 100644 text/paginate/layout.go diff --git a/text/paginate/README.md b/text/paginate/README.md index 75f2cfc06e..6022b6ed1c 100644 --- a/text/paginate/README.md +++ b/text/paginate/README.md @@ -1,5 +1,20 @@ # Paginate -The `paginate` package takes a set of input Widget trees and returns a corresponding set of page Frame widgets that fit within a specified height, with headers and page numbers specified using a template. +The `paginate` package takes a set of input Widget trees and returns a corresponding set of page Frame widgets that fit within a specified height, with optional title, headers and footers. +The main purpose is for generating PDF output, via the PDF function, which installs default PDF fonts (Helvetica, Times, Courier) and renders output. + +The first step involves extracting a list of leaf-level widgets from surrounding core.Frame elements, that are then processed by the layout function to fit into page-sized chunks. This can be controlled by the properties as described below. + +## Properties + +Properties can be set on widgets to inform the pagination process. This is done by the `content` package, for example. All properties start with `paginate-`. + +* `block` -- marks a Frame as a block that is not to be further extracted from in collecting leaves. Only Frame elements that have direction = Column are + +* `float-top` -- marks a `block` frame to be floated to the top of a page + +* `break` -- starts a new page before this element. + +* `no-break-after` -- marks an element to not have a page break inserted after it. diff --git a/text/paginate/extract.go b/text/paginate/extract.go new file mode 100644 index 0000000000..88ca3d5481 --- /dev/null +++ b/text/paginate/extract.go @@ -0,0 +1,92 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paginate + +import ( + "cogentcore.org/core/base/stack" + "cogentcore.org/core/core" + "cogentcore.org/core/math32" + "cogentcore.org/core/styles" +) + +// item is one layout item +type item struct { + w core.Widget + gap math32.Vector2 // gap to add before this element + left float32 // left-side margin from parent frame +} + +func (it *item) String() string { + return core.AsWidget(it.w).String() +} + +// extract returns the widget chunks to actually paginate. +func (p *pager) extract() []*item { + widg := core.AsWidget + + ii := 0 + type posn struct { + w core.Widget + i int + } + + pars := stack.Stack[*posn]{} // stack of parents that we are iterating + pars.Push(&posn{p.ins[ii], 0}) + atEnd := false + + next := func() { + start: + cp := pars.Peek() + cp.i++ + if cp.i >= cp.w.AsWidget().NumChildren() { + pars.Pop() + if len(pars) == 0 { + ii++ + if ii >= len(p.ins) { + atEnd = true + return + } + pars.Push(&posn{p.ins[ii], 0}) + return + } else { + goto start + } + } + } + + var its []*item + for { + cp := pars.Peek() + cpw := cp.w.AsWidget() + if cp.i >= cpw.NumChildren() { + next() + if atEnd { + break + } + continue + } + gap := cpw.Styles.Gap.Dots().Floor() + // todo: left margin + if cp.i == 0 { + gap.Y = 0 + } + cw := widg(cpw.Child(cp.i)) + if fr, ok := cw.This.(*core.Frame); ok { + if fr.Styles.Direction == styles.Column { + if fr.Property("paginate-block") == nil { + pars.Push(&posn{fr.This.(core.Widget), 0}) + continue + } + } + } + its = append(its, &item{w: cw.This.(core.Widget), gap: gap}) + next() + if atEnd { + break + } + } + // fmt.Println("its:", its) + return its +} diff --git a/text/paginate/layout.go b/text/paginate/layout.go new file mode 100644 index 0000000000..5a4ed2f230 --- /dev/null +++ b/text/paginate/layout.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paginate + +import ( + "cogentcore.org/core/core" + "cogentcore.org/core/math32" + "cogentcore.org/core/tree" +) + +// pagify organizes widget list into page-sized chunks. +func (p *pager) pagify(its []*item) [][]*item { + widg := core.AsWidget + size := func(it *item) float32 { + return widg(it.w).Geom.Size.Actual.Total.Y + it.gap.Y + } + + maxY := p.opts.bodyDots.Y + + var pgs [][]*item + var cpg []*item + ht := float32(0) + n := len(its) + for i, ci := range its { + cw := widg(ci.w) + brk := cw.Property("paginate-break") != nil + nobrk := cw.Property("paginate-no-break-after") != nil + sz := size(ci) + over := ht+sz > maxY + if !over && nobrk { + if i < n-1 { + nsz := size(its[i+1]) + if ht+sz+nsz > maxY { + over = true // break now + } + } + } + if brk || over { + if !brk && len(cpg) == 0 { // no blank pages! + cpg = append(cpg, ci) + } + pgs = append(pgs, cpg) + cpg = nil + ht = 0 + } + ht += sz + cpg = append(cpg, ci) + } + return pgs +} + +// layout reorders items within the pages and generates final output. +func (p *pager) layout(its []*item) { + pgs := p.pagify(its) + for _, pg := range pgs { + // todo: rearrange elements to put text at bottom and non-text at top + + gap := math32.Vector2{} + if len(pg) > 0 { + gap = pg[0].gap // todo: better + } + // now transfer over to frame + page, body := p.newPage(gap) + for _, it := range pg { + tree.MoveToParent(it.w, body) + } + p.outs = append(p.outs, page) + } +} diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index 00d459810b..86ad5f8c4c 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -5,11 +5,9 @@ package paginate import ( - "cogentcore.org/core/base/stack" "cogentcore.org/core/core" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" - "cogentcore.org/core/tree" ) // Paginate organizes the given input widget content into frames @@ -71,91 +69,6 @@ func (p *pager) preRender() { } func (p *pager) paginate() { - p.opts.Update() - p.ctx = p.ins[0].(core.Widget).AsWidget().Styles.UnitContext - p.opts.ToDots(&p.ctx) - widg := core.AsWidget - - ii := 0 - type posn struct { - w core.Widget - i int - } - - pars := stack.Stack[*posn]{} - pars.Push(&posn{p.ins[ii], 0}) - atEnd := false - gap := p.ins[0].AsWidget().Styles.Gap.Dots().Floor() - - next := func() { - start: - cp := pars.Peek() - cp.i++ - if cp.i >= cp.w.AsWidget().NumChildren() { - pars.Pop() - if len(pars) == 0 { - ii++ - if ii >= len(p.ins) { - atEnd = true - return - } - pars.Push(&posn{p.ins[ii], 0}) - return - } else { - goto start - } - } - } - - maxY := p.opts.bodyDots.Y - // per page, list of widgets -- must accumulate all, to not change structure - var ws [][]core.Widget - for { - var cws []core.Widget - ht := float32(0) - for { - cp := pars.Peek() - cpw := cp.w.AsWidget() - if cp.i >= cpw.NumChildren() { - next() - if atEnd { - break - } - continue - } - cw := widg(cpw.Child(cp.i)) - sz := cw.Geom.Size.Actual.Total.Y - if ht+sz > maxY { - if fr, ok := cw.This.(*core.Frame); ok { - pars.Push(&posn{fr.This.(core.Widget), 0}) - continue - } - if len(cws) == 0 { - cws = append(cws, cw.This.(core.Widget)) - next() - } - break - } - ht += sz + gap.Y - cws = append(cws, cw.This.(core.Widget)) - next() - if atEnd { - break - } - } - ws = append(ws, cws) - if atEnd { - break - } - } - - for _, cws := range ws { - // todo: rearrange elements to put text at bottom and non-text at top - // now transfer over to frame - page, body := p.newPage(gap) - for _, w := range cws { - tree.MoveToParent(w, body) - } - p.outs = append(p.outs, page) - } + its := p.extract() + p.layout(its) } From cc25528ebdfabeb6fa41572ee0c610d5000167a8 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 6 Oct 2025 06:31:11 +0200 Subject: [PATCH 21/99] pdf: render.Context stores both the incremental Transform and Cumulative transform -- pdf and svg renderers actually need the incremental one, not Cumulative! fixes pdf rendering of svgs (modulo missing features). --- paint/painter.go | 2 +- paint/render/context.go | 9 +++++++-- paint/render/item.go | 2 +- paint/renderers/rasterx/renderer.go | 8 ++++---- paint/renderers/rasterx/text.go | 12 ++++++------ svg/svg_test.go | 2 +- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/paint/painter.go b/paint/painter.go index 83a492f42f..9e94b1802a 100644 --- a/paint/painter.go +++ b/paint/painter.go @@ -58,7 +58,7 @@ func NewPainter(size math32.Vector2) *Painter { } func (pc *Painter) Transform() math32.Matrix2 { - return pc.Context().Transform.Mul(pc.Paint.Transform) + return pc.Context().Cumulative.Mul(pc.Paint.Transform) } //////// Path basics diff --git a/paint/render/context.go b/paint/render/context.go index 80143d2985..04f6f23637 100644 --- a/paint/render/context.go +++ b/paint/render/context.go @@ -49,9 +49,12 @@ type Context struct { // Individual elements inherit from this style. Style styles.Paint - // Transform is the accumulated transformation matrix. + // Transform is the transformation matrix for this stack level. Transform math32.Matrix2 + // Cumulative is the accumulated transformation matrix. + Cumulative math32.Matrix2 + // Bounds is the rounded rectangle clip boundary. // This is applied to the effective Path prior to adding to Render. Bounds Bounds @@ -92,12 +95,14 @@ func (ctx *Context) Init(sty *styles.Paint, bounds *Bounds, parent *Context) { } if parent == nil { ctx.Transform = sty.Transform + ctx.Cumulative = sty.Transform ctx.SetBounds(bounds) ctx.ClipPath = sty.ClipPath ctx.Mask = sty.Mask return } - ctx.Transform = parent.Transform.Mul(ctx.Style.Transform) + ctx.Transform = ctx.Style.Transform + ctx.Cumulative = parent.Cumulative.Mul(ctx.Style.Transform) ctx.Style.InheritFields(&parent.Style) if bounds == nil { bounds = &parent.Bounds diff --git a/paint/render/item.go b/paint/render/item.go index f83dae8c98..ca77c97caf 100644 --- a/paint/render/item.go +++ b/paint/render/item.go @@ -25,7 +25,7 @@ func (p *ContextPush) IsRenderItem() { } func (p *ContextPush) String() string { - return "ctx-push: " + p.Context.Transform.String() + return "ctx-push: " + p.Context.Cumulative.String() } // ContextPop is a [Context] pop render item, which can be used by renderers diff --git a/paint/renderers/rasterx/renderer.go b/paint/renderers/rasterx/renderer.go index b61c3aacd3..647af02c3b 100644 --- a/paint/renderers/rasterx/renderer.go +++ b/paint/renderers/rasterx/renderer.go @@ -82,7 +82,7 @@ func (rs *Renderer) RenderPath(pt *render.Path) { } pc := &pt.Context rs.Scanner.SetClip(pc.Bounds.Rect.ToRect()) - PathToRasterx(&rs.Path, p, pt.Context.Transform, math32.Vector2{}) + PathToRasterx(&rs.Path, p, pt.Context.Cumulative, math32.Vector2{}) rs.Fill(pt) rs.Stroke(pt) rs.Path.Clear() @@ -120,7 +120,7 @@ func (rs *Renderer) Stroke(pt *render.Path) { dash := slices.Clone(sty.Stroke.Dashes) if dash != nil { - scx, scy := pc.Transform.ExtractScale() + scx, scy := pc.Cumulative.ExtractScale() sc := 0.5 * (math32.Abs(scx) + math32.Abs(scy)) for i := range dash { dash[i] *= sc @@ -143,7 +143,7 @@ func (rs *Renderer) SetColor(sc Scanner, pc *render.Context, clr image.Image, op fbox := sc.GetPathExtent() lastRenderBBox := image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} - g.Update(opacity, math32.B2FromRect(lastRenderBBox), pc.Transform) + g.Update(opacity, math32.B2FromRect(lastRenderBBox), pc.Cumulative) sc.SetColor(clr) } else { if opacity < 1 { @@ -187,7 +187,7 @@ func (rs *Renderer) StrokeWidth(pt *render.Path) float32 { if sty.VectorEffect == ppath.VectorEffectNonScalingStroke { return dw } - sc := MeanScale(pt.Context.Transform) + sc := MeanScale(pt.Context.Cumulative) return sc * dw } diff --git a/paint/renderers/rasterx/text.go b/paint/renderers/rasterx/text.go index 25db91478c..36f895e0c8 100644 --- a/paint/renderers/rasterx/text.go +++ b/paint/renderers/rasterx/text.go @@ -35,7 +35,7 @@ func (rs *Renderer) RenderText(txt *render.Text) { // The text will be drawn starting at the start pixel position, which specifies the // left baseline location of the first text item.. func (rs *Renderer) TextLines(ctx *render.Context, lns *shaped.Lines, pos math32.Vector2) { - m := ctx.Transform + m := ctx.Cumulative identity := m == math32.Identity2() off := pos.Add(lns.Offset) rs.Scanner.SetClip(ctx.Bounds.Rect.ToRect()) @@ -120,7 +120,7 @@ func (rs *Renderer) TextRun(ctx *render.Context, run *shapedgt.Run, ln *shaped.L lineW := max(fsz/16, 1) // 1 at 16, bigger if biggerr if run.Math.Path != nil { rs.Path.Clear() - PathToRasterx(&rs.Path, *run.Math.Path, ctx.Transform, off) + PathToRasterx(&rs.Path, *run.Math.Path, ctx.Cumulative, off) rf := &rs.Raster.Filler rf.SetWinding(true) rf.SetColor(fill) @@ -204,7 +204,7 @@ func (rs *Renderer) GlyphOutline(ctx *render.Context, run *shapedgt.Run, g *shap } rs.Path.Clear() - m := ctx.Transform + m := ctx.Cumulative for _, s := range outline.Segments { p0 := m.MulVector2AsPoint(math32.Vec2(s.Args[0].X*scale+x, -s.Args[0].Y*scale+y)) switch s.Op { @@ -265,7 +265,7 @@ func (rs *Renderer) StrokeBounds(ctx *render.Context, bb math32.Box2, clr color. ButtCap, nil, nil, Miter, nil, 0) rs.Raster.SetColor(colors.Uniform(clr)) - m := ctx.Transform + m := ctx.Cumulative rs.Raster.Start(m.MulVector2AsPoint(math32.Vec2(bb.Min.X, bb.Min.Y)).ToFixed()) rs.Raster.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Min.Y)).ToFixed()) rs.Raster.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Max.Y)).ToFixed()) @@ -277,7 +277,7 @@ func (rs *Renderer) StrokeBounds(ctx *render.Context, bb math32.Box2, clr color. // StrokeTextLine strokes a line for text decoration. func (rs *Renderer) StrokeTextLine(ctx *render.Context, sp, ep math32.Vector2, width float32, clr image.Image, dash []float32) { - m := ctx.Transform + m := ctx.Cumulative sp = m.MulVector2AsPoint(sp) ep = m.MulVector2AsPoint(ep) width *= MeanScale(m) @@ -298,7 +298,7 @@ func (rs *Renderer) StrokeTextLine(ctx *render.Context, sp, ep math32.Vector2, w func (rs *Renderer) FillBounds(ctx *render.Context, bb math32.Box2, clr image.Image) { rf := &rs.Raster.Filler rf.SetColor(clr) - m := ctx.Transform + m := ctx.Cumulative rf.Start(m.MulVector2AsPoint(math32.Vec2(bb.Min.X, bb.Min.Y)).ToFixed()) rf.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Min.Y)).ToFixed()) rf.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Max.Y)).ToFixed()) diff --git a/svg/svg_test.go b/svg/svg_test.go index cd12b30bef..8a4e9ef030 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -46,7 +46,7 @@ func RunTest(t *testing.T, width, height int, dir, fname string) { pdfn := filepath.Join(pddir, bnm+".pdf") ctx := units.NewContext() pd := paint.NewPDFRenderer(size, ctx) - pd.SetSize(units.UnitPx, size) // should be correct + // pd.SetSize(units.UnitPx, size) // should be correct // pd.SetSize(units.UnitMm, size) // to see everything pd.Render(rend) os.MkdirAll(pddir, 0777) From 8691e1145300109d51b0fbe60895b499d1c1edc2 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 6 Oct 2025 06:36:20 +0200 Subject: [PATCH 22/99] pdf: minor svg_test fixup --- svg/svg_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/svg/svg_test.go b/svg/svg_test.go index 8a4e9ef030..9a7475c962 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -27,6 +27,8 @@ import ( ) func RunTest(t *testing.T, width, height int, dir, fname string) { + // cset := pdf.UseStandardFonts() + size := math32.Vec2(float32(width), float32(height)) sv := NewSVG(size) svfn := filepath.Join("testdata", dir, fname) @@ -41,17 +43,16 @@ func RunTest(t *testing.T, width, height int, dir, fname string) { // fmt.Println(svfn, imfn) imagex.Assert(t, img, imfn) - // todo: sizing and offset is not right -- need to install that somehow. pddir := filepath.Join("testdata", dir, "pdf") pdfn := filepath.Join(pddir, bnm+".pdf") ctx := units.NewContext() pd := paint.NewPDFRenderer(size, ctx) - // pd.SetSize(units.UnitPx, size) // should be correct - // pd.SetSize(units.UnitMm, size) // to see everything pd.Render(rend) os.MkdirAll(pddir, 0777) err = os.WriteFile(pdfn, pd.Source(), 0666) assert.NoError(t, err) + + // pdf.RestorePreviousFonts(cset) } func TestSVG(t *testing.T) { From c4904c4fa2fddbb0ff5e7f7a9453acd101efe37a Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 6 Oct 2025 07:00:39 +0200 Subject: [PATCH 23/99] pdf: fix shaped_test -- emoji is not working since some point! --- paint/pdf/text.go | 7 ++-- text/shaped/shaped_test.go | 71 ++++++++++++++++++-------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/paint/pdf/text.go b/paint/pdf/text.go index 3e6052ea56..ba86bd63a0 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -100,6 +100,7 @@ func (r *PDF) textRunRegions(m math32.Matrix2, run *shapedgt.Run, ln *shaped.Lin func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, ln *shaped.Line, lns *shaped.Lines, runes []rune, clr image.Image, off math32.Vector2) { // dir := run.Direction region := run.Runes() + offTrans := math32.Translate2D(off.X, off.Y) rbb := run.MaxBounds.Translate(off) fill := clr if run.FillColor != nil { @@ -108,7 +109,9 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, fsz := math32.FromFixed(run.Size) lineW := max(fsz/16, 1) // 1 at 16, bigger if biggerr if run.Math.Path != nil { - r.Path(*run.Math.Path, style, math32.Translate2D(off.X, off.Y)) + r.w.PushTransform(offTrans) + r.Path(*run.Math.Path, style, math32.Identity2()) + r.w.PopStack() return } @@ -132,7 +135,7 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, } } - r.w.StartTextObject(math32.Translate2D(off.X, off.Y)) + r.w.StartTextObject(offTrans) r.setTextStyle(&run.Font, style, fill, run.StrokeColor, math32.FromFixed(run.Size), lns.LineHeight) raw := string(runes[region.Start:region.End]) r.w.WriteText(raw) diff --git a/text/shaped/shaped_test.go b/text/shaped/shaped_test.go index 6219cab0cd..4e735ead4b 100644 --- a/text/shaped/shaped_test.go +++ b/text/shaped/shaped_test.go @@ -27,15 +27,12 @@ import ( "cogentcore.org/core/text/text" "cogentcore.org/core/text/textpos" "github.com/go-text/typesetting/font" - "github.com/go-text/typesetting/language" "github.com/stretchr/testify/assert" ) // RunTest makes a rendering state, paint, and image with the given size, calls the given // function, and then asserts the image using [imagex.Assert] with the given name. -func RunTest(t *testing.T, nm string, width int, height int, f func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings)) { - rts := &rich.Settings{} - rts.Defaults() +func RunTest(t *testing.T, nm string, width int, height int, f func(pc *paint.Painter, sh Shaper, tsty *text.Style)) { uc := units.Context{} uc.Defaults() tsty := text.NewStyle() @@ -45,13 +42,13 @@ func RunTest(t *testing.T, nm string, width int, height int, f func(pc *paint.Pa pc := paint.NewPainter(sz) pc.FillBox(math32.Vector2{}, sz, colors.Uniform(colors.White)) sh := NewShaper() - f(pc, sh, tsty, rts) + f(pc, sh, tsty) img := paint.RenderToImage(pc) imagex.Assert(t, img, nm) } func TestFontMapper(t *testing.T) { - RunTest(t, "fontmapper", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "fontmapper", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { fname := "Noto Sans" for i := range 2 { sty := rich.NewStyle() @@ -80,7 +77,7 @@ func TestFontMapper(t *testing.T) { tx.AddSpan(&medit, []rune("This is Medium Italic\n")) tx.AddSpan(&boldit, []rune("This is Bold Italic")) // fmt.Println(tx) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pos := math32.Vec2(10, float32(10+i*150)) pc.DrawText(lns, pos) @@ -103,7 +100,7 @@ func TestFontMapper(t *testing.T) { } func TestBasic(t *testing.T) { - RunTest(t, "basic", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "basic", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { src := "The lazy fox typed in some familiar text" sr := []rune(src) @@ -122,7 +119,7 @@ func TestBasic(t *testing.T) { tx.AddSpan(boldBig, sr[ix:ix+8]) tx.AddSpan(ul, sr[ix+8:]) - lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, plain, tsty, math32.Vec2(250, 250)) lns.SelectRegion(textpos.Range{7, 30}) lns.SelectRegion(textpos.Range{34, 40}) pos := math32.Vec2(20, 60) @@ -155,7 +152,7 @@ func TestBasic(t *testing.T) { } func TestHebrew(t *testing.T) { - RunTest(t, "hebrew", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "hebrew", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { tsty.Direction = rich.RTL tsty.FontSize.Dots *= 1.5 @@ -165,15 +162,15 @@ func TestHebrew(t *testing.T) { plain := rich.NewStyle() tx := rich.NewText(plain, sr) - lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, plain, tsty, math32.Vec2(250, 250)) pc.DrawText(lns, math32.Vec2(20, 60)) }) } func TestVertical(t *testing.T) { - RunTest(t, "nihongo_ttb", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { - rts.Language = "ja" - rts.Script = language.Han + RunTest(t, "nihongo_ttb", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { + // rts.Language = "ja" + // rts.Script = language.Han tsty.Direction = rich.TTB // rich.BTT // note: apparently BTT is actually never used tsty.FontSize.Dots *= 1.5 @@ -186,14 +183,14 @@ func TestVertical(t *testing.T) { sr := []rune(src) tx := rich.NewText(plain, sr) - lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(150, 50)) + lns := sh.WrapLines(tx, plain, tsty, math32.Vec2(150, 50)) // pc.DrawText(lns, math32.Vec2(100, 200)) pc.DrawText(lns, math32.Vec2(60, 100)) }) - RunTest(t, "nihongo_ltr", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { - rts.Language = "ja" - rts.Script = language.Han + RunTest(t, "nihongo_ltr", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { + // rts.Language = "ja" + // rts.Script = language.Han tsty.FontSize.Dots *= 1.5 // todo: word wrapping and sideways rotation in vertical not currently working @@ -202,13 +199,13 @@ func TestVertical(t *testing.T) { plain := rich.NewStyle() tx := rich.NewText(plain, sr) - lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, plain, tsty, math32.Vec2(250, 250)) pc.DrawText(lns, math32.Vec2(20, 60)) }) } func TestColors(t *testing.T) { - RunTest(t, "colors", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "colors", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { tsty.FontSize.Dots *= 4 stroke := rich.NewStyle().SetStrokeColor(colors.Red).SetBackground(colors.ToUniform(colors.Scheme.Select.Container)) @@ -220,28 +217,28 @@ func TestColors(t *testing.T) { tx := rich.NewText(stroke, sr[:4]) tx.AddSpan(&big, sr[4:8]).AddSpan(stroke, sr[8:]) - lns := sh.WrapLines(tx, stroke, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, stroke, tsty, math32.Vec2(250, 250)) pc.DrawText(lns, math32.Vec2(20, 10)) }) } func TestLink(t *testing.T) { - RunTest(t, "link", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "link", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { src := `The link and it is cool and` sty := rich.NewStyle() tx, err := htmltext.HTMLToRich([]byte(src), sty, nil) assert.NoError(t, err) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pc.DrawText(lns, math32.Vec2(10, 10)) }) } func TestSpacePos(t *testing.T) { - RunTest(t, "space-pos", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "space-pos", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { src := `The and` sty := rich.NewStyle() tx := rich.NewText(sty, []rune(src)) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pos := math32.Vec2(10, 10) pc.DrawText(lns, pos) @@ -256,13 +253,13 @@ func TestSpacePos(t *testing.T) { } func TestLinefeed(t *testing.T) { - RunTest(t, "linefeed", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "linefeed", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { src := "Text2D can put HTML
formatted Text anywhere you might want" sty := rich.NewStyle() tx, err := htmltext.HTMLToRich([]byte(src), sty, nil) // fmt.Println(tx) assert.NoError(t, err) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pos := math32.Vec2(10, 10) pc.DrawText(lns, pos) @@ -276,27 +273,27 @@ func TestLinefeed(t *testing.T) { } func TestLineCentering(t *testing.T) { - RunTest(t, "linecentering", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "linecentering", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { src := "This is Line Centering" // src := "aceg" sty := rich.NewStyle() tsty.LineHeight = 3 tx := rich.NewText(sty, []rune(src)) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pos := math32.Vec2(10, 10) pc.DrawText(lns, pos) }) } func TestEmoji(t *testing.T) { - RunTest(t, "emoji", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "emoji", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { // src := "the \U0001F615\U0001F618\U0001F616 !!" // smileys src := "the 🎁🎉, !!" sty := rich.NewStyle() sty.Size = 3 // sty.Family = rich.Monospace tx := rich.NewText(sty, []rune(src)) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) // fmt.Println(lns) pos := math32.Vec2(10, 10) pc.DrawText(lns, pos) @@ -338,13 +335,13 @@ func TestMathInline(t *testing.T) { // continue // } fnm := "math-inline-" + test.name - RunTest(t, fnm, 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, fnm, 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { src := test.math sty := rich.NewStyle() tx := rich.NewText(sty, []rune("math: ")) tx.AddMathInline(sty, src) tx.AddSpan(sty, []rune(" and we should check line wrapping too")) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pos := math32.Vec2(10, 10) pc.DrawText(lns, pos) }) @@ -364,12 +361,12 @@ func TestMathDisplay(t *testing.T) { // continue // } fnm := "math-display-" + test.name - RunTest(t, fnm, 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, fnm, 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { src := test.math sty := rich.NewStyle() var tx rich.Text tx.AddMathDisplay(sty, src) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pos := math32.Vec2(10, 10) pc.DrawText(lns, pos) }) @@ -377,14 +374,14 @@ func TestMathDisplay(t *testing.T) { } func TestWhitespacePre(t *testing.T) { - RunTest(t, "whitespacepre", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + RunTest(t, "whitespacepre", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { tsty.WhiteSpace = text.WhiteSpacePre src := "This is not going to wrap even if it goes over\nWhiteSpacePre does that for you" sty := rich.NewStyle() tx, err := htmltext.HTMLPreToRich([]byte(src), sty, nil) assert.NoError(t, err) // fmt.Println(tx) - lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + lns := sh.WrapLines(tx, sty, tsty, math32.Vec2(250, 250)) pos := math32.Vec2(10, 10) pc.DrawText(lns, pos) tsty.WhiteSpace = text.WrapAsNeeded From 86cdc08100fb328eedb9694ec0e8f703c0f914f4 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 6 Oct 2025 17:05:36 +0200 Subject: [PATCH 24/99] pdf: paginate title example working, and content settings --- content/content.go | 9 ++-- content/examples/basic/basic.go | 1 + content/examples/basic/content/button.md | 1 + svg/svg_test.go | 2 +- text/paginate/layout.go | 2 +- text/paginate/options.go | 20 +++++--- text/paginate/page.go | 12 ++--- text/paginate/paginate.go | 14 +++++- text/paginate/paginate_test.go | 3 +- text/paginate/{render.go => pdf.go} | 4 +- text/paginate/runners.go | 59 ++++++++++++++++++++++++ text/paginate/typegen.go | 8 +++- 12 files changed, 107 insertions(+), 28 deletions(-) rename text/paginate/{render.go => pdf.go} (92%) diff --git a/content/content.go b/content/content.go index 3ffb99e27a..8a6704de43 100644 --- a/content/content.go +++ b/content/content.go @@ -38,7 +38,6 @@ import ( "cogentcore.org/core/system" "cogentcore.org/core/text/csl" "cogentcore.org/core/text/paginate" - "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -553,12 +552,10 @@ func (ct *Content) PagePDF(path string) error { if errors.Log(err) != nil { return err } - opts := paginate.NewOptions() - opts.FontFamily = rich.Serif - opts.Header = paginate.HeaderLeftPageNumber(ct.currentPage.Name) - opts.Footer = nil - paginate.PDF(f, opts, ct.rightFrame) + opts := Settings.PageSettings(ct.currentPage) + paginate.PDF(f, opts.PDF, ct.rightFrame) err = f.Close() ct.reloadPage() + core.MessageSnackbar(ct, "PDF saved to: "+fname) return err } diff --git a/content/examples/basic/basic.go b/content/examples/basic/basic.go index 2a2f9ebdde..c4c694bc95 100644 --- a/content/examples/basic/basic.go +++ b/content/examples/basic/basic.go @@ -17,6 +17,7 @@ import ( var econtent embed.FS func main() { + content.Settings.SiteTitle = "Cogent Content Example" b := core.NewBody("Cogent Content Example") ct := content.NewContent(b).SetContent(econtent) ct.Context.AddWikilinkHandler(htmlcore.GoDocWikilink("doc", "cogentcore.org/core")) diff --git a/content/examples/basic/content/button.md b/content/examples/basic/content/button.md index 9db1c8015e..a43c643d62 100644 --- a/content/examples/basic/content/button.md +++ b/content/examples/basic/content/button.md @@ -1,5 +1,6 @@ +++ Categories = ["Widgets"] +Authors = ["Bea A. Author", "Test Ing Name"] +++ A **button** is a [[widget]] that a user can click on to trigger a described action. See [[func button]] for a button [[value binding|bound]] to a function. There are various [[#types]] of buttons. diff --git a/svg/svg_test.go b/svg/svg_test.go index 9a7475c962..8663c1f574 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -155,7 +155,7 @@ func TestEmoji(t *testing.T) { } func TestFontEmoji(t *testing.T) { - t.Skip("special-case testing -- requires noto-emoji file") + // t.Skip("special-case testing -- requires noto-emoji file") // dir := filepath.Join("testdata", "noto-emoji") os.MkdirAll("testdata/font-emoji-src", 0777) fname := "/Library/Fonts/NotoColorEmoji-Regular.ttf" diff --git a/text/paginate/layout.go b/text/paginate/layout.go index 5a4ed2f230..d1aff53dd0 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -17,7 +17,7 @@ func (p *pager) pagify(its []*item) [][]*item { return widg(it.w).Geom.Size.Actual.Total.Y + it.gap.Y } - maxY := p.opts.bodyDots.Y + maxY := p.opts.BodyDots.Y var pgs [][]*item var cpg []*item diff --git a/text/paginate/options.go b/text/paginate/options.go index e3530ee0bf..e86e7ef2b7 100644 --- a/text/paginate/options.go +++ b/text/paginate/options.go @@ -53,9 +53,15 @@ type Options struct { // See examples in runners.go Footer func(frame *core.Frame, opts *Options, pageNo int) - sizeDots math32.Vector2 // total size in dots - bodyDots math32.Vector2 // body (content) size in dots - margDots sides.Floats // margin sizes in dots + // SizeDots is the total size in dots. Set automatically, but needs to be readable + // so is exported. + SizeDots math32.Vector2 `edit:"-"` + + // BodyDots (content) size in dots. + BodyDots math32.Vector2 `edit:"-"` + + // MargDots is the margin sizes in dots. + MargDots sides.Floats `edit:"-"` } func NewOptions() Options { @@ -80,8 +86,8 @@ func (o *Options) Update() { func (o *Options) ToDots(un *units.Context) { sc := un.ToDots(1, o.Units) - o.sizeDots = o.Size.MulScalar(sc) - o.margDots = o.Margins.MulScalar(sc) - o.bodyDots.X = o.sizeDots.X - (o.margDots.Left + o.margDots.Right) - o.bodyDots.Y = o.sizeDots.Y - (o.margDots.Top + o.margDots.Bottom) + o.SizeDots = o.Size.MulScalar(sc) + o.MargDots = o.Margins.MulScalar(sc) + o.BodyDots.X = o.SizeDots.X - (o.MargDots.Left + o.MargDots.Right) + o.BodyDots.Y = o.SizeDots.Y - (o.MargDots.Top + o.MargDots.Bottom) } diff --git a/text/paginate/page.go b/text/paginate/page.go index d1ea3b3350..afafa6dce2 100644 --- a/text/paginate/page.go +++ b/text/paginate/page.go @@ -28,26 +28,26 @@ func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { page.SetName(pn) page.Styler(func(s *styles.Style) { s.Direction = styles.Row - styMinMax(s, p.opts.sizeDots.X, p.opts.sizeDots.Y) + styMinMax(s, p.opts.SizeDots.X, p.opts.SizeDots.Y) }) lmar := core.NewFrame(page) lmar.SetName("left-margin") lmar.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.margDots.Left, p.opts.sizeDots.Y) + styMinMax(s, p.opts.MargDots.Left, p.opts.SizeDots.Y) }) bfr := core.NewFrame(page) bfr.SetName("body-frame") bfr.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.bodyDots.X, p.opts.sizeDots.Y) + styMinMax(s, p.opts.BodyDots.X, p.opts.SizeDots.Y) }) hdr := core.NewFrame(bfr) hdr.SetName("header") hdr.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.bodyDots.X, p.opts.margDots.Top) + styMinMax(s, p.opts.BodyDots.X, p.opts.MargDots.Top) }) if p.opts.Header != nil { p.opts.Header(hdr, p.opts, curPage) @@ -57,7 +57,7 @@ func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { body.SetName("body") body.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.bodyDots.X, p.opts.bodyDots.Y) + styMinMax(s, p.opts.BodyDots.X, p.opts.BodyDots.Y) s.Gap.X.Dot(gap.X) s.Gap.Y.Dot(gap.Y) }) @@ -66,7 +66,7 @@ func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { ftr.SetName("footer") ftr.Styler(func(s *styles.Style) { s.Direction = styles.Column - styMinMax(s, p.opts.bodyDots.X, p.opts.margDots.Bottom) + styMinMax(s, p.opts.BodyDots.X, p.opts.MargDots.Bottom) }) if p.opts.Footer != nil { p.opts.Footer(ftr, p.opts, curPage) diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index 86ad5f8c4c..1a2bf82d28 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -45,12 +45,22 @@ func (p *pager) optsUpdate() { // preRender re-renders inputs with styles enforced to fit page size, // and setting the font family and size for text elements. func (p *pager) preRender() { + if p.opts.Title != nil { + tf := core.NewFrame() + tf.Scene = core.AsWidget(p.ins[0]).Scene + tf.FinalStyler(func(s *styles.Style) { + s.Min.X.Dot(p.opts.BodyDots.X) + s.Min.Y.Dot(p.opts.BodyDots.Y) + }) + p.opts.Title(tf, p.opts) + p.ins = append([]core.Widget{tf.This.(core.Widget)}, p.ins...) + } for _, in := range p.ins { iw := core.AsWidget(in) iw.FinalStyler(func(s *styles.Style) { - s.Min.X.Dot(p.opts.bodyDots.X) - s.Min.Y.Dot(p.opts.bodyDots.Y) + s.Min.X.Dot(p.opts.BodyDots.X) + s.Min.Y.Dot(p.opts.BodyDots.Y) }) iw.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { if _, ok := cwb.This.(*core.Text); ok { diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index dfce9a9291..4151ac587e 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -31,8 +31,9 @@ func RunTest(t *testing.T, nm string, f func() *core.Body) { opts := NewOptions() opts.FontFamily = rich.Serif opts.Header = HeaderLeftPageNumber("This is a test header") + opts.Title = CenteredTitle("This is a Profound Statement of Something Important", "Bea A. Author", "University of Twente
Department of Physiology", "The thing about this paper is that it is dealing with an issue that should be given more attention, but perhaps it really is hard to understand and that makes it difficult to get the attention it deserves. In any case, we are very proud.") buff := bytes.Buffer{} - PDF(&buff, opts, b) + PDF(&buff, opts, &b.Frame) os.Mkdir("testdata", 0777) os.WriteFile(filepath.Join("testdata", nm)+".pdf", buff.Bytes(), 0666) } diff --git a/text/paginate/render.go b/text/paginate/pdf.go similarity index 92% rename from text/paginate/render.go rename to text/paginate/pdf.go index a98f5c98e0..7eec91fc19 100644 --- a/text/paginate/render.go +++ b/text/paginate/pdf.go @@ -33,11 +33,11 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { sc := core.NewScene() sz := math32.Geom2DInt{} - sz.Size = opts.sizeDots.ToPointCeil() + sz.Size = opts.SizeDots.ToPointCeil() sc.Resize(sz) sc.MakeTextShaper() - pdr := paint.NewPDFRenderer(opts.sizeDots, &p.ctx).(*pdfrender.Renderer) + pdr := paint.NewPDFRenderer(opts.SizeDots, &p.ctx).(*pdfrender.Renderer) pdr.StartRender(w) np := len(p.outs) for i, p := range p.outs { diff --git a/text/paginate/runners.go b/text/paginate/runners.go index f82776ae6f..664f4196c6 100644 --- a/text/paginate/runners.go +++ b/text/paginate/runners.go @@ -10,6 +10,7 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/styles" "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" ) // CenteredPageNumber generates a page number cenetered in the frame @@ -74,3 +75,61 @@ func HeaderLeftPageNumber(header string) func(frame *core.Frame, opts *Options, }) } } + +// CenteredTitle inserts centered text elements for each element if non-empty. +func CenteredTitle(title, authors, affiliations, abstract string) func(frame *core.Frame, opts *Options) { + return func(frame *core.Frame, opts *Options) { + fr := core.NewFrame(frame) + fr.Styler(func(s *styles.Style) { + s.Direction = styles.Column + s.Grow.Set(1, 0) + s.Align.Items = styles.Center + }) + fr.SetProperty("paginate-block", true) + + core.NewStretch(fr).Styler(func(s *styles.Style) { // need this to take up the full width + s.Grow.Set(1, 0) + s.Min.X.Dot(opts.BodyDots.X) + s.Min.Y.Em(.1) + }) + core.NewText(fr).SetText(title).Styler(func(s *styles.Style) { + s.Font.Family = opts.FontFamily + s.Font.Size.Pt(16) + s.Text.Align = text.Center + }) + core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) + + if authors != "" { + core.NewText(fr).SetText(authors).Styler(func(s *styles.Style) { + s.Font.Family = opts.FontFamily + s.Font.Size.Pt(11) + s.Text.Align = text.Center + }) + core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) + } + + if affiliations != "" { + core.NewText(fr).SetText(affiliations).Styler(func(s *styles.Style) { + s.Font.Family = opts.FontFamily + s.Font.Slant = rich.Italic + s.Font.Size.Pt(10) + s.Text.Align = text.Center + }) + core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) + } + + if abstract != "" { + core.NewText(fr).SetText("Abstract:").Styler(func(s *styles.Style) { + s.Font.Family = opts.FontFamily + s.Font.Size.Pt(11) + }) + core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(.5) }) + core.NewText(fr).SetText(abstract).Styler(func(s *styles.Style) { + s.Font.Family = opts.FontFamily + s.Font.Size.Pt(10) + s.Align.Self = styles.Start + }) + core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) + } + } +} diff --git a/text/paginate/typegen.go b/text/paginate/typegen.go index a09908c724..16c0aff0c9 100644 --- a/text/paginate/typegen.go +++ b/text/paginate/typegen.go @@ -6,6 +6,10 @@ import ( "cogentcore.org/core/types" ) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.Options", IDName: "options", Doc: "Options has the parameters for pagination.", Fields: []types.Field{{Name: "PageSize", Doc: "PageSize specifies a standard page size, or Custom."}, {Name: "Units", Doc: "Units are the units in which size is specified.\nWill automatically be set if PageSize != Custom."}, {Name: "Size", Doc: "Size is the size in given units.\nWill automatically be set if PageSize != Custom."}, {Name: "Margins", Doc: "Margins specify the page margins in the size units."}, {Name: "Header", Doc: "Header is the header template string, with #\nreplaced with the page number\n adds a stretch element that can be used to accomplish\njustification: at start = right justify, at start and end = center"}, {Name: "Footer", Doc: "Footer is the footer template string, with #\nreplaced with the page number.\n adds a stretch element that can be used to accomplish\njustification: at start = right justify, at start and end = center"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.item", IDName: "item", Doc: "item is one layout item", Fields: []types.Field{{Name: "w"}, {Name: "gap"}, {Name: "left"}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.pager", IDName: "pager", Doc: "pager implements the pagination.", Fields: []types.Field{{Name: "opts"}, {Name: "ins"}, {Name: "outs"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.posn", IDName: "posn", Fields: []types.Field{{Name: "w"}, {Name: "i"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.Options", IDName: "options", Doc: "Options has the parameters for pagination.", Fields: []types.Field{{Name: "PageSize", Doc: "PageSize specifies a standard page size, or Custom."}, {Name: "Units", Doc: "Units are the units in which size is specified.\nWill automatically be set if PageSize != Custom."}, {Name: "Size", Doc: "Size is the size in given units.\nWill automatically be set if PageSize != Custom."}, {Name: "Margins", Doc: "Margins specify the page margins in the size units."}, {Name: "FontFamily", Doc: "FontFamily specifies the default font family to apply\nto all core.Text elements."}, {Name: "FontSize", Doc: "FontSize specifies the default font size to apply\nto all core.Text elements, if non-zero."}, {Name: "Title", Doc: "Title generates the title contents for the first page,\ninto the given page body frame."}, {Name: "Header", Doc: "Header generates the header contents for the page, into the given\nframe that represents the entire top margin.\nSee examples in runners.go"}, {Name: "Footer", Doc: "Footer generates the footer contents for the page, into the given\nframe that represents the entire top margin.\nSee examples in runners.go"}, {Name: "SizeDots", Doc: "SizeDots is the total size in dots. Set automatically, but needs to be readable\nso is exported."}, {Name: "BodyDots", Doc: "BodyDots (content) size in dots."}, {Name: "MargDots", Doc: "MargDots is the margin sizes in dots."}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/paginate.pager", IDName: "pager", Doc: "pager implements the pagination.", Fields: []types.Field{{Name: "opts"}, {Name: "ins"}, {Name: "outs"}, {Name: "ctx"}}}) From a9af36890d967f7680713b953a2f6a925ab55fff Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 6 Oct 2025 23:26:49 +0200 Subject: [PATCH 25/99] pdf: title setting url default etc --- content/content.go | 15 +++++++-- content/examples/basic/basic.go | 1 + content/settings.go | 59 +++++++++++++++++++++++++++++++++ content/url_js.go | 2 ++ content/url_noop.go | 12 +++++-- 5 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 content/settings.go diff --git a/content/content.go b/content/content.go index 8a6704de43..0f06f4df8e 100644 --- a/content/content.go +++ b/content/content.go @@ -105,6 +105,11 @@ type Content struct { // if any (in kebab-case). currentHeading string + // noTitleAuthors is used to turn off the addition of title and authors + // in prep for PDF rendering, which includes those elements in a more + // print-conventional manner typically. + noTitleAuthors bool + // The previous and next page, if applicable. They must be stored on this struct // to avoid stale local closure variables. prevPage, nextPage *bcontent.Page @@ -226,7 +231,7 @@ func (ct *Content) Init() { } }) w.Maker(func(p *tree.Plan) { - if ct.currentPage.Title != "" { + if !ct.noTitleAuthors && ct.currentPage.Title != "" { tree.Add(p, func(w *core.Text) { w.SetType(core.TextDisplaySmall) w.Updater(func() { @@ -234,7 +239,7 @@ func (ct *Content) Init() { }) }) } - if len(ct.currentPage.Authors) > 0 { + if !ct.noTitleAuthors && len(ct.currentPage.Authors) > 0 { tree.Add(p, func(w *core.Text) { w.SetType(core.TextTitleLarge) w.Updater(func() { @@ -544,6 +549,10 @@ func (ct *Content) PagePDF(path string) error { if ct.currentPage == nil { return errors.Log(errors.New("Page empty")) } + ct.noTitleAuthors = true + ct.Update() + ct.noTitleAuthors = false + fname := ct.currentPage.Name + ".pdf" if path != "" { fname = filepath.Join(path, fname) @@ -552,7 +561,7 @@ func (ct *Content) PagePDF(path string) error { if errors.Log(err) != nil { return err } - opts := Settings.PageSettings(ct.currentPage) + opts := Settings.PageSettings(ct, ct.currentPage) paginate.PDF(f, opts.PDF, ct.rightFrame) err = f.Close() ct.reloadPage() diff --git a/content/examples/basic/basic.go b/content/examples/basic/basic.go index c4c694bc95..e2cb43d9c5 100644 --- a/content/examples/basic/basic.go +++ b/content/examples/basic/basic.go @@ -18,6 +18,7 @@ var econtent embed.FS func main() { content.Settings.SiteTitle = "Cogent Content Example" + content.OfflineURL = "https://example.com" b := core.NewBody("Cogent Content Example") ct := content.NewContent(b).SetContent(econtent) ct.Context.AddWikilinkHandler(htmlcore.GoDocWikilink("doc", "cogentcore.org/core")) diff --git a/content/settings.go b/content/settings.go new file mode 100644 index 0000000000..a85a83ccd6 --- /dev/null +++ b/content/settings.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package content + +import ( + "strings" + + "cogentcore.org/core/content/bcontent" + "cogentcore.org/core/text/paginate" + "cogentcore.org/core/text/rich" +) + +func init() { + Settings.Defaults() +} + +// Settings are the current settings for content rendering. +var Settings SettingsData + +// SettingsData has settings parameters for content, +// including PDF rendering options. +type SettingsData struct { + PDF paginate.Options + + // SiteTitle is the title of the site, used in page headings and titles. + SiteTitle string + + // PageSettings is a function that returns the settings data to use + // for the current page. Can set custom parameters for different pages. + // The default sets the PDF Header function to HeaderLeftPageNumber + // with current page Title. + PageSettings func(ct *Content, curPage *bcontent.Page) *SettingsData +} + +func (s *SettingsData) Defaults() { + s.PDF.Defaults() + s.PDF.FontFamily = rich.Serif + s.PDF.Footer = nil + + s.PageSettings = func(ct *Content, curPage *bcontent.Page) *SettingsData { + ps := &SettingsData{} + *ps = Settings + pt := curPage.Title + if ps.SiteTitle != "" { + pt = ps.SiteTitle + ": " + pt + } + ps.PDF.Header = paginate.HeaderLeftPageNumber(pt) + au := "" + if len(curPage.Authors) > 0 { + au = strings.Join(curPage.Authors, ", ") + } + // todo: add affiliations and abstract + af := ct.getPrintURL() + "/" + curPage.URL + ps.PDF.Title = paginate.CenteredTitle(pt, au, af, "") + return ps + } +} diff --git a/content/url_js.go b/content/url_js.go index 3eb794fae3..fae4fa1843 100644 --- a/content/url_js.go +++ b/content/url_js.go @@ -20,6 +20,8 @@ var firstContent *Content var documentData = js.Global().Get("document").Get("documentElement").Get("dataset") +func (ct *Content) getPrintURL() string { return ct.getWebURL() } + // getWebURL returns the current relative web URL that should be passed to [Content.Open] // on startup and in [Content.handleWebPopState]. func (ct *Content) getWebURL() string { diff --git a/content/url_noop.go b/content/url_noop.go index faa6ac859a..7e757d2352 100644 --- a/content/url_noop.go +++ b/content/url_noop.go @@ -6,6 +6,12 @@ package content -func (ct *Content) getWebURL() string { return "" } -func (ct *Content) saveWebURL() {} -func (ct *Content) handleWebPopState() {} +// OfflineURL is the non-web base url, which can be set to allow +// docs to refer to this in frontmatter. +var OfflineURL = "" + +// just for printing +func (ct *Content) getPrintURL() string { return OfflineURL } +func (ct *Content) getWebURL() string { return "" } +func (ct *Content) saveWebURL() {} +func (ct *Content) handleWebPopState() {} From 6a8d75f54acd6331cc7c6c4336cfc516ffb8d993 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 09:14:37 +0200 Subject: [PATCH 26/99] pdf: title etc updated, added elements to content, changed content Authors to just a string so you can do random formatting on it. --- content/bcontent/page.go | 12 +++++- content/content.go | 2 +- content/examples/basic/content/button.md | 4 +- content/settings.go | 19 +++++---- core/render.go | 3 ++ core/snackbar_test.go | 6 +-- text/paginate/paginate.go | 18 +++++--- text/paginate/paginate_test.go | 6 +-- text/paginate/runners.go | 52 +++++++++++++----------- 9 files changed, 75 insertions(+), 47 deletions(-) diff --git a/content/bcontent/page.go b/content/bcontent/page.go index 83cce85dfd..b3758d2b4b 100644 --- a/content/bcontent/page.go +++ b/content/bcontent/page.go @@ -52,8 +52,16 @@ type Page struct { // DateString is only used for parsing the date from the TOML front matter. DateString string `toml:"Date" json:"-"` - // Authors are the optional authors of the page. - Authors []string + // Authors are the optional author(s) of the page. + Authors string + + // Affiliations are optional institutional affiliations of the authors. + // This is only used for the PDF output. + Affiliations string + + // Abstract is an optional abstract. + // This is only used for the PDF output. + Abstract string // Draft indicates that the page is a draft and should not be visible on the web. Draft bool diff --git a/content/content.go b/content/content.go index 0f06f4df8e..fa0d652de6 100644 --- a/content/content.go +++ b/content/content.go @@ -243,7 +243,7 @@ func (ct *Content) Init() { tree.Add(p, func(w *core.Text) { w.SetType(core.TextTitleLarge) w.Updater(func() { - w.SetText("By " + strcase.FormatList(ct.currentPage.Authors...)) + w.SetText("By " + ct.currentPage.Authors) }) }) } diff --git a/content/examples/basic/content/button.md b/content/examples/basic/content/button.md index a43c643d62..fd80bf1aaa 100644 --- a/content/examples/basic/content/button.md +++ b/content/examples/basic/content/button.md @@ -1,6 +1,8 @@ +++ Categories = ["Widgets"] -Authors = ["Bea A. Author", "Test Ing Name"] +Authors = "Bea A. Author1 and Test Ing Name2" +Affiliations = "1University of Somwhere 2University of Elsewhere" +Abstract = "The button is an essential element of any GUI framework, with the capability of triggering actions of any sort. Actions are very important because they allow people to actually do something." +++ A **button** is a [[widget]] that a user can click on to trigger a described action. See [[func button]] for a button [[value binding|bound]] to a function. There are various [[#types]] of buttons. diff --git a/content/settings.go b/content/settings.go index a85a83ccd6..2a20ba9151 100644 --- a/content/settings.go +++ b/content/settings.go @@ -5,7 +5,7 @@ package content import ( - "strings" + "time" "cogentcore.org/core/content/bcontent" "cogentcore.org/core/text/paginate" @@ -46,14 +46,17 @@ func (s *SettingsData) Defaults() { if ps.SiteTitle != "" { pt = ps.SiteTitle + ": " + pt } - ps.PDF.Header = paginate.HeaderLeftPageNumber(pt) - au := "" - if len(curPage.Authors) > 0 { - au = strings.Join(curPage.Authors, ", ") - } + ps.PDF.Header = paginate.NoFirst(paginate.HeaderLeftPageNumber(pt)) // todo: add affiliations and abstract - af := ct.getPrintURL() + "/" + curPage.URL - ps.PDF.Title = paginate.CenteredTitle(pt, au, af, "") + ur := ct.getPrintURL() + "/" + curPage.URL + ura := `` + ur + `` + dt := "" + if !curPage.Date.IsZero() { + dt = curPage.Date.Format("January 2, 2006") + } else { + dt = time.Now().Format("January 2, 2006") + } + ps.PDF.Title = paginate.CenteredTitle(pt, curPage.Authors, curPage.Affiliations, ura, dt, curPage.Abstract) return ps } } diff --git a/core/render.go b/core/render.go index 2137c8d38a..d1847ba907 100644 --- a/core/render.go +++ b/core/render.go @@ -325,6 +325,9 @@ func (wb *WidgetBase) StartRender() bool { return false } wb.Styles.ComputeActualBackground(wb.parentActualBackground()) + if wb.Scene == nil { + return false + } pc := &wb.Scene.Painter if pc.State == nil { return false diff --git a/core/snackbar_test.go b/core/snackbar_test.go index 56f4b01b38..e3ba943f1a 100644 --- a/core/snackbar_test.go +++ b/core/snackbar_test.go @@ -71,10 +71,10 @@ func TestSnackbarError(t *testing.T) { func TestSnackbarTime(t *testing.T) { t.Skip("TODO(#1456): fix this test") - ptimeout := SystemSettings.SnackbarTimeout - SystemSettings.SnackbarTimeout = 50 * time.Millisecond + ptimeout := TimingSettings.SnackbarTimeout + TimingSettings.SnackbarTimeout = 50 * time.Millisecond defer func() { - SystemSettings.SnackbarTimeout = ptimeout + TimingSettings.SnackbarTimeout = ptimeout }() times := []time.Duration{0, 25 * time.Millisecond, 75 * time.Millisecond} for _, tm := range times { diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index 1a2bf82d28..30191c1315 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -8,6 +8,7 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" ) // Paginate organizes the given input widget content into frames @@ -45,9 +46,11 @@ func (p *pager) optsUpdate() { // preRender re-renders inputs with styles enforced to fit page size, // and setting the font family and size for text elements. func (p *pager) preRender() { + sc := core.AsWidget(p.ins[0]).Scene + // sc.AsyncLock() if p.opts.Title != nil { tf := core.NewFrame() - tf.Scene = core.AsWidget(p.ins[0]).Scene + tf.Scene = sc tf.FinalStyler(func(s *styles.Style) { s.Min.X.Dot(p.opts.BodyDots.X) s.Min.Y.Dot(p.opts.BodyDots.Y) @@ -63,11 +66,13 @@ func (p *pager) preRender() { s.Min.Y.Dot(p.opts.BodyDots.Y) }) iw.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { - if _, ok := cwb.This.(*core.Text); ok { - if _, ok := cwb.Parent.(*core.Frame); ok { // not inside buttons etc - cwb.Styler(func(s *styles.Style) { - s.Font.Family = p.opts.FontFamily - }) + if tx, ok := cwb.This.(*core.Text); ok { + if tx.Styles.Font.Family == rich.SansSerif { + if _, ok := cwb.Parent.(*core.Frame); ok { // not inside buttons etc + cwb.Styler(func(s *styles.Style) { + s.Font.Family = p.opts.FontFamily + }) + } } } return true @@ -76,6 +81,7 @@ func (p *pager) preRender() { iw.Scene.StyleTree() iw.Scene.LayoutRenderScene() } + // sc.AsyncUnlock() } func (p *pager) paginate() { diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index 4151ac587e..c06f390a97 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -30,10 +30,10 @@ func RunTest(t *testing.T, nm string, f func() *core.Body) { opts := NewOptions() opts.FontFamily = rich.Serif - opts.Header = HeaderLeftPageNumber("This is a test header") - opts.Title = CenteredTitle("This is a Profound Statement of Something Important", "Bea A. Author", "University of Twente
Department of Physiology", "The thing about this paper is that it is dealing with an issue that should be given more attention, but perhaps it really is hard to understand and that makes it difficult to get the attention it deserves. In any case, we are very proud.") + opts.Header = NoFirst(HeaderLeftPageNumber("This is a test header")) + opts.Title = CenteredTitle("This is a Profound Statement of Something Important", "Bea A. Author", "University of Twente
Department of Physiology", "March 1, 2024", `https://example.com/testing`, "The thing about this paper is that it is dealing with an issue that should be given more attention, but perhaps it really is hard to understand and that makes it difficult to get the attention it deserves. In any case, we are very proud.") buff := bytes.Buffer{} - PDF(&buff, opts, &b.Frame) + PDF(&buff, opts, b) os.Mkdir("testdata", 0777) os.WriteFile(filepath.Join("testdata", nm)+".pdf", buff.Bytes(), 0666) } diff --git a/text/paginate/runners.go b/text/paginate/runners.go index 664f4196c6..2290b996c3 100644 --- a/text/paginate/runners.go +++ b/text/paginate/runners.go @@ -29,23 +29,14 @@ func CenteredPageNumber(frame *core.Frame, opts *Options, pageNo int) { core.NewText(fr).SetText(strconv.Itoa(pageNo)) } -// CenteredPageNumberNoFirst generates a page number cenetered in the frame -// with a 1.5em space above it. Skips the first one. -func CenteredPageNumberNoFirst(frame *core.Frame, opts *Options, pageNo int) { - if pageNo == 1 { - return +// NoFirst excludes the first page for any runner +func NoFirst(fun func(frame *core.Frame, opts *Options, pageNo int)) func(frame *core.Frame, opts *Options, pageNo int) { + return func(frame *core.Frame, opts *Options, pageNo int) { + if pageNo == 1 { + return + } + fun(frame, opts, pageNo) } - core.NewSpace(frame).Styler(func(s *styles.Style) { // space before - s.Min.Y.Em(1.5) - s.Grow.Set(1, 0) - }) - fr := core.NewFrame(frame) - fr.Styler(func(s *styles.Style) { - s.Direction = styles.Row - s.Grow.Set(1, 0) - s.Justify.Content = styles.Center - }) - core.NewText(fr).SetText(strconv.Itoa(pageNo)) } // HeaderLeftPageNumber adds a running header with page number on the right. @@ -77,7 +68,7 @@ func HeaderLeftPageNumber(header string) func(frame *core.Frame, opts *Options, } // CenteredTitle inserts centered text elements for each element if non-empty. -func CenteredTitle(title, authors, affiliations, abstract string) func(frame *core.Frame, opts *Options) { +func CenteredTitle(title, authors, affiliations, url, date, abstract string) func(frame *core.Frame, opts *Options) { return func(frame *core.Frame, opts *Options) { fr := core.NewFrame(frame) fr.Styler(func(s *styles.Style) { @@ -97,7 +88,6 @@ func CenteredTitle(title, authors, affiliations, abstract string) func(frame *co s.Font.Size.Pt(16) s.Text.Align = text.Center }) - core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) if authors != "" { core.NewText(fr).SetText(authors).Styler(func(s *styles.Style) { @@ -105,31 +95,47 @@ func CenteredTitle(title, authors, affiliations, abstract string) func(frame *co s.Font.Size.Pt(11) s.Text.Align = text.Center }) - core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) } if affiliations != "" { core.NewText(fr).SetText(affiliations).Styler(func(s *styles.Style) { s.Font.Family = opts.FontFamily - s.Font.Slant = rich.Italic + s.Font.Size.Pt(10) + s.Text.Align = text.Center + s.Text.LineHeight = 1.1 + }) + } + core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) + + if date != "" { + core.NewText(fr).SetText(date).Styler(func(s *styles.Style) { + s.Font.Family = opts.FontFamily + s.Font.Size.Pt(10) + s.Text.Align = text.Center + }) + } + + if url != "" { + core.NewText(fr).SetText(url).Styler(func(s *styles.Style) { + s.Font.Family = opts.FontFamily s.Font.Size.Pt(10) s.Text.Align = text.Center }) - core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) } if abstract != "" { core.NewText(fr).SetText("Abstract:").Styler(func(s *styles.Style) { s.Font.Family = opts.FontFamily s.Font.Size.Pt(11) + s.Font.Weight = rich.Bold + s.Align.Self = styles.Start }) - core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(.5) }) core.NewText(fr).SetText(abstract).Styler(func(s *styles.Style) { s.Font.Family = opts.FontFamily s.Font.Size.Pt(10) s.Align.Self = styles.Start }) - core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) } + core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) } } From 8001db580b31b4fffb14718c18680d4054c94252 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 10:58:07 +0200 Subject: [PATCH 27/99] pdf: start on links: got positioning right finally, but need to deal with url handler --- paint/pdf/page.go | 8 ++++++-- paint/pdf/text.go | 47 +++++++++++++++-------------------------------- text/rich/link.go | 4 ---- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/paint/pdf/page.go b/paint/pdf/page.go index daec74ebbe..4e2b236e25 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -72,13 +72,17 @@ func (w *pdfPage) writePage(parent pdfRef) pdfRef { return w.pdf.writeObject(page) } -// AddAnnotation adds an annotation. +// AddAnnotation adds an annotation. The rect is in "default user space" +// coordinates = standard page coordinates, without the current CTM transform. +// This function will handle the flipping of coordinates to top-left system. func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { + ms := math32.Scale2D(w.pdf.globalScale, w.pdf.globalScale) + rect = rect.MulMatrix2(ms) annot := pdfDict{ "Type": pdfName("Annot"), "Subtype": pdfName("Link"), "Border": pdfArray{0, 0, 0}, - "Rect": pdfArray{rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y}, + "Rect": pdfArray{rect.Min.X, w.height - rect.Max.Y, rect.Max.X, w.height - rect.Min.Y}, "Contents": uri, "A": pdfDict{ "S": pdfName("URI"), diff --git a/paint/pdf/text.go b/paint/pdf/text.go index ba86bd63a0..bef9b0b95f 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -33,6 +33,7 @@ func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, ln ln := &lns.Lines[li] r.textLine(style, m, ln, lns, runes, clr, off) } + r.links(lns, m, pos) r.w.PopStack() } @@ -200,35 +201,17 @@ func (r *PDF) FillBox(m math32.Matrix2, bb math32.Box2, clr image.Image) { r.Path(*p, sty, m) } -// text.WalkDecorations(func(fill canvas.Paint, p *canvas.Path) { -// style := canvas.DefaultStyle -// style.Fill = fill -// r.RenderPath(p, style, m) -// }) - -// todo: copy from other render cases -// text.WalkSpans(func(x, y float32, span canvas.TextSpan) { -// if span.IsText() { -// style := canvas.DefaultStyle -// style.Fill = span.Face.Fill -// -// r.w.StartTextObject() -// r.w.SetFill(span.Face.Fill) -// r.w.SetFont(span.Face.Font, span.Face.Size, span.Direction) -// r.w.SetTextPosition(m.Translate(x, y).Shear(span.Face.FauxItalic, 0.0)) -// -// if 0.0 < span.Face.FauxBold { -// r.w.SetTextRenderMode(2) -// r.w.SetStroke(span.Face.Fill) -// fmt.Fprintf(r.w, " %v w", dec(span.Face.FauxBold*2.0)) -// } else { -// r.w.SetTextRenderMode(0) -// } -// r.w.WriteText(text.WritingMode, span.Glyphs) -// r.w.EndTextObject() -// } else { -// for _, obj := range span.Objects { -// obj.Canvas.RenderViewTo(r, m.Mul(obj.View(x, y, span.Face))) -// } -// } -// }) +func (r *PDF) links(lns *shaped.Lines, m math32.Matrix2, pos math32.Vector2) { + lks := lns.GetLinks() + for _, lk := range lks { + // note: link coordinates are in default user space, not current transform. + srb := lns.RuneBounds(lk.Range.Start) + erb := lns.RuneBounds(lk.Range.End) + if erb.Max.X > srb.Max.X { + srb.Max.X = erb.Max.X + } + rb := srb.Translate(pos) + rb = rb.MulMatrix2(m) + r.w.AddURIAction(lk.URL, rb) + } +} diff --git a/text/rich/link.go b/text/rich/link.go index 51a654340e..7ef5e620e1 100644 --- a/text/rich/link.go +++ b/text/rich/link.go @@ -16,10 +16,6 @@ type Hyperlink struct { // URL is the full URL for the link. URL string - // Properties are additional properties defined for the link, - // e.g., from the parsed HTML attributes. TODO: resolve - // Properties map[string]any - // Range defines the starting and ending positions of the link, // in terms of source rune indexes. Range textpos.Range From d92aca4aad08b5b1bde7af6d06bf5605273da470 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 12:21:41 +0200 Subject: [PATCH 28/99] pdf: math rendering working --- content/examples/basic/basic.go | 1 + content/examples/basic/content/button.md | 6 ++++ paint/pdf/page.go | 3 +- paint/pdf/pdf_test.go | 41 ++++++++++++++++++++++++ paint/pdf/text.go | 28 ++++++++++------ paint/pdf/writer.go | 2 +- text/paginate/layout.go | 3 ++ text/paginate/paginate.go | 1 + text/paginate/paginate_test.go | 2 ++ 9 files changed, 75 insertions(+), 12 deletions(-) diff --git a/content/examples/basic/basic.go b/content/examples/basic/basic.go index e2cb43d9c5..a913aac88e 100644 --- a/content/examples/basic/basic.go +++ b/content/examples/basic/basic.go @@ -10,6 +10,7 @@ import ( "cogentcore.org/core/content" "cogentcore.org/core/core" "cogentcore.org/core/htmlcore" + _ "cogentcore.org/core/text/tex" _ "cogentcore.org/core/yaegicore" ) diff --git a/content/examples/basic/content/button.md b/content/examples/basic/content/button.md index fd80bf1aaa..a0110b43ae 100644 --- a/content/examples/basic/content/button.md +++ b/content/examples/basic/content/button.md @@ -9,6 +9,12 @@ A **button** is a [[widget]] that a user can click on to trigger a described act ## Properties +Buttons can do math? $x = y^2$ and even display math: + +$$ +y = \frac{1}{N} \left( \sum_{i=0}^{100} \frac{f(x^2)}{\sum x^2} \right) +$$ + You can make a button with text: ```Go diff --git a/paint/pdf/page.go b/paint/pdf/page.go index 4e2b236e25..d4ec1b3e50 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -74,7 +74,8 @@ func (w *pdfPage) writePage(parent pdfRef) pdfRef { // AddAnnotation adds an annotation. The rect is in "default user space" // coordinates = standard page coordinates, without the current CTM transform. -// This function will handle the flipping of coordinates to top-left system. +// This function will handle the base page transform for scaling and +// flipping of coordinates to top-left system. func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { ms := math32.Scale2D(w.pdf.globalScale, w.pdf.globalScale) rect = rect.MulMatrix2(ms) diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 8b82115bbe..4468a1eadd 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -19,8 +19,10 @@ import ( "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/htmltext" + "cogentcore.org/core/text/rich" "cogentcore.org/core/text/shaped" _ "cogentcore.org/core/text/shaped/shapers" + _ "cogentcore.org/core/text/tex" "github.com/alecthomas/assert/v2" ) @@ -72,3 +74,42 @@ func TestText(t *testing.T) { RestorePreviousFonts(prv) }) } + +func TestMathInline(t *testing.T) { + RunTest(t, "math-inline", 300, 300, func(pd *PDF, sty *styles.Paint) { + prv := UseStandardFonts() + sh := shaped.NewShaper() + + src := `y = \frac{1}{N} \left( \sum_{i=0}^{100} \frac{f(x^2)}{\sum x^2} \right)` + rsty := &sty.Font + tsty := &sty.Text + + tx := rich.NewText(rsty, []rune("math: ")) + tx.AddMathInline(rsty, src) + tx.AddSpan(rsty, []rune(" and we should check line wrapping too")) + lns := sh.WrapLines(tx, rsty, tsty, math32.Vec2(250, 250)) + + m := math32.Identity2() + pd.Text(sty, m, math32.Vec2(20, 20), lns) + RestorePreviousFonts(prv) + }) +} + +func TestMathDisplay(t *testing.T) { + RunTest(t, "math-display", 300, 300, func(pd *PDF, sty *styles.Paint) { + prv := UseStandardFonts() + sh := shaped.NewShaper() + + src := `y = \frac{1}{N} \left( \sum_{i=0}^{100} \frac{f(x^2)}{\sum x^2} \right)` + rsty := &sty.Font + tsty := &sty.Text + + var tx rich.Text + tx.AddMathDisplay(rsty, src) + lns := sh.WrapLines(tx, rsty, tsty, math32.Vec2(250, 250)) + + m := math32.Identity2() + pd.Text(sty, m, math32.Vec2(20, 20), lns) + RestorePreviousFonts(prv) + }) +} diff --git a/paint/pdf/text.go b/paint/pdf/text.go index bef9b0b95f..c2c9ebb85e 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -111,7 +111,10 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, lineW := max(fsz/16, 1) // 1 at 16, bigger if biggerr if run.Math.Path != nil { r.w.PushTransform(offTrans) - r.Path(*run.Math.Path, style, math32.Identity2()) + psty := *style + psty.Stroke.Color = run.StrokeColor + psty.Fill.Color = fill + r.Path(*run.Math.Path, &psty, math32.Identity2()) r.w.PopStack() return } @@ -151,6 +154,18 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, } } +func (r *PDF) setTextStrokeColor(clr image.Image) { + sc := r.w.style().Stroke + sc.Color = clr + r.w.SetStroke(&sc) +} + +func (r *PDF) setTextFillColor(clr image.Image) { + fc := r.w.style().Fill + fc.Color = clr + r.w.SetFill(&fc) +} + // setTextStyle applies the given styles. func (r *PDF) setTextStyle(fnt *text.Font, style *styles.Paint, fill, stroke image.Image, size, lineHeight float32) { tsty := &style.Text @@ -158,17 +173,10 @@ func (r *PDF) setTextStyle(fnt *text.Font, style *styles.Paint, fill, stroke ima r.w.SetFont(sty, tsty) mode := 0 if stroke != nil { - sc := styles.Stroke{} - sc.Defaults() - sc.Color = stroke - r.w.SetStroke(&sc) + r.setTextStrokeColor(stroke) } if fill != nil { - fc := styles.Fill{} - fc.Defaults() - fc.Color = fill - fc.Opacity = 1 - r.w.SetFill(&fc) + r.setTextFillColor(fill) if stroke != nil { mode = 2 } diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index d41f65aa51..93b4753499 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -69,7 +69,7 @@ func newPDFWriter(writer io.Writer, un *units.Context) *pdfWriter { // fontsH: map[*text.Font]pdfRef{}, // fontsV: map[*text.Font]pdfRef{}, images: map[image.Image]pdfRef{}, - compress: true, + compress: false, subset: true, } w.layerInit() diff --git a/text/paginate/layout.go b/text/paginate/layout.go index d1aff53dd0..c870027bd7 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -48,6 +48,9 @@ func (p *pager) pagify(its []*item) [][]*item { ht += sz cpg = append(cpg, ci) } + if len(cpg) > 0 { + pgs = append(pgs, cpg) + } return pgs } diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index 30191c1315..e98245eda8 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -9,6 +9,7 @@ import ( "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" + _ "cogentcore.org/core/text/tex" ) // Paginate organizes the given input widget content into frames diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index c06f390a97..2b979df6bb 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -33,7 +33,9 @@ func RunTest(t *testing.T, nm string, f func() *core.Body) { opts.Header = NoFirst(HeaderLeftPageNumber("This is a test header")) opts.Title = CenteredTitle("This is a Profound Statement of Something Important", "Bea A. Author", "University of Twente
Department of Physiology", "March 1, 2024", `https://example.com/testing`, "The thing about this paper is that it is dealing with an issue that should be given more attention, but perhaps it really is hard to understand and that makes it difficult to get the attention it deserves. In any case, we are very proud.") buff := bytes.Buffer{} + b.AsyncLock() PDF(&buff, opts, b) + b.AsyncUnlock() os.Mkdir("testdata", 0777) os.WriteFile(filepath.Join("testdata", nm)+".pdf", buff.Bytes(), 0666) } From a8a136db1358ba01e74fef35005c2485f019a73b Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 15:58:31 +0200 Subject: [PATCH 29/99] pdf: content and htmlcore element handling improved: setting tags always, using to center figures, prevent page breaks on headers, etc. --- content/content.go | 47 ++++++++++++++++++++++++------- core/render.go | 29 ++++++++++++-------- htmlcore/context.go | 3 +- htmlcore/handler.go | 58 ++++++++++++++++++++++++--------------- text/paginate/layout.go | 17 ++++++++++-- text/paginate/paginate.go | 3 ++ 6 files changed, 109 insertions(+), 48 deletions(-) diff --git a/content/content.go b/content/content.go index fa0d652de6..7f0ae108ae 100644 --- a/content/content.go +++ b/content/content.go @@ -105,10 +105,9 @@ type Content struct { // if any (in kebab-case). currentHeading string - // noTitleAuthors is used to turn off the addition of title and authors - // in prep for PDF rendering, which includes those elements in a more - // print-conventional manner typically. - noTitleAuthors bool + // inPDFRender indicates that it is rendering a PDF now, turning off + // elements that are not appropriate for that. + inPDFRender bool // The previous and next page, if applicable. They must be stored on this struct // to avoid stale local closure variables. @@ -197,19 +196,45 @@ func (ct *Content) Init() { ct.Context.AddWidgetHandler(func(w core.Widget) { switch x := w.(type) { case *core.Text: + hdr := false + if t, ok := x.Properties["tag"]; ok { + if len(t.(string)) > 0 && (t.(string))[0] == 'h' { + hdr = true + } + } x.Styler(func(s *styles.Style) { - s.Max.X.In(8) // doesn't constrain PDF render + s.Max.X.In(8) // big enough to not constrain PDF render + if hdr { + x.SetProperty("paginate-no-break-after", true) + } }) case *core.Image: + fig := false + if t, ok := x.Properties["tag"]; ok { + fig = (t.(string) == "figure") // images with id set are considered images, center + } x.Styler(func(s *styles.Style) { s.SetAbilities(true, abilities.Clickable, abilities.DoubleClickable) s.Overflow.Set(styles.OverflowAuto) + if fig { + s.Align.Self = styles.Center + } }) x.OnDoubleClick(func(e events.Event) { d := core.NewBody("Image") core.NewImage(d).SetImage(x.Image) d.RunWindowDialog(x) }) + case *core.Frame: + table := false + if t, ok := x.Properties["tag"]; ok { + table = (t.(string) == "table") + } + if table { + x.Styler(func(s *styles.Style) { + s.Align.Self = styles.Center + }) + } } }) @@ -231,7 +256,7 @@ func (ct *Content) Init() { } }) w.Maker(func(p *tree.Plan) { - if !ct.noTitleAuthors && ct.currentPage.Title != "" { + if !ct.inPDFRender && ct.currentPage.Title != "" { tree.Add(p, func(w *core.Text) { w.SetType(core.TextDisplaySmall) w.Updater(func() { @@ -239,7 +264,7 @@ func (ct *Content) Init() { }) }) } - if !ct.noTitleAuthors && len(ct.currentPage.Authors) > 0 { + if !ct.inPDFRender && len(ct.currentPage.Authors) > 0 { tree.Add(p, func(w *core.Text) { w.SetType(core.TextTitleLarge) w.Updater(func() { @@ -264,7 +289,9 @@ func (ct *Content) Init() { errors.Log(ct.loadPage(w)) }) }) - ct.makeBottomButtons(p) + if !ct.inPDFRender { + ct.makeBottomButtons(p) + } }) }) }) @@ -549,9 +576,9 @@ func (ct *Content) PagePDF(path string) error { if ct.currentPage == nil { return errors.Log(errors.New("Page empty")) } - ct.noTitleAuthors = true + ct.inPDFRender = true ct.Update() - ct.noTitleAuthors = false + ct.inPDFRender = false fname := ct.currentPage.Name + ".pdf" if path != "" { diff --git a/core/render.go b/core/render.go index d1847ba907..2775fb7267 100644 --- a/core/render.go +++ b/core/render.go @@ -127,36 +127,41 @@ func (sc *Scene) LayoutScene() { if DebugSettings.LayoutTrace { fmt.Println("\n############################\nLayoutScene SizeUp start:", sc) } - sc.SizeUp() - sz := &sc.Geom.Size - sz.Alloc.Total.SetPoint(sc.SceneGeom.Size) + sc.LayoutFrame(math32.FromPoint(sc.SceneGeom.Size)) +} + +// LayoutFrame does a layout on the given Frame using given size. +func (fr *Frame) LayoutFrame(size math32.Vector2) { + fr.SizeUp() + sz := &fr.Geom.Size + sz.Alloc.Total = size sz.SetContentFromTotal(&sz.Alloc) if DebugSettings.LayoutTrace { - fmt.Println("\n############################\nSizeDown start:", sc) + fmt.Println("\n############################\nSizeDown start:", fr) } maxIter := 3 for iter := 0; iter < maxIter; iter++ { // 3 > 2; 4 same as 3 - redo := sc.SizeDown(iter) + redo := fr.SizeDown(iter) if redo && iter < maxIter-1 { if DebugSettings.LayoutTrace { - fmt.Println("\n############################\nSizeDown redo:", sc, "iter:", iter+1) + fmt.Println("\n############################\nSizeDown redo:", fr, "iter:", iter+1) } } else { break } } if DebugSettings.LayoutTrace { - fmt.Println("\n############################\nSizeFinal start:", sc) + fmt.Println("\n############################\nSizeFinal start:", fr) } - sc.SizeFinal() + fr.SizeFinal() if DebugSettings.LayoutTrace { - fmt.Println("\n############################\nPosition start:", sc) + fmt.Println("\n############################\nPosition start:", fr) } - sc.Position() + fr.Position() if DebugSettings.LayoutTrace { - fmt.Println("\n############################\nScenePos start:", sc) + fmt.Println("\n############################\nScenePos start:", fr) } - sc.ApplyScenePos() + fr.ApplyScenePos() } // LayoutRenderScene does a layout and render of the tree: diff --git a/htmlcore/context.go b/htmlcore/context.go index 1bbce9a32a..229dacf546 100644 --- a/htmlcore/context.go +++ b/htmlcore/context.go @@ -165,7 +165,7 @@ func (c *Context) config(w core.Widget) { wb.SetProperty(attr.Key, attr.Val) } } - wb.SetProperty("tag", c.Node.Data) + wb.SetProperty("tag", c.Node.Data) // this is needed by handleWidget in general rules := c.styles[c.Node] wb.Styler(func(s *styles.Style) { for _, rule := range rules { @@ -175,7 +175,6 @@ func (c *Context) config(w core.Widget) { } } }) - c.handleWidget(w) } // InlineParent returns the current parent widget that inline diff --git a/htmlcore/handler.go b/htmlcore/handler.go index d245e496f0..195e620a7e 100644 --- a/htmlcore/handler.go +++ b/htmlcore/handler.go @@ -33,7 +33,7 @@ import ( func New[T tree.NodeValue](ctx *Context) *T { parent := ctx.Parent() w := tree.New[T](parent) - ctx.config(any(w).(core.Widget)) // TODO: better htmlcore structure with new config paradigm? + ctx.config(any(w).(core.Widget)) return w } @@ -54,6 +54,8 @@ func handleElement(ctx *Context) { return } + var newWidget core.Widget + switch tag { case "script", "title", "meta": // we don't render anything @@ -81,6 +83,7 @@ func handleElement(ctx *Context) { ctx.addStyle(ExtractText(ctx)) case "body", "main", "div", "section", "nav", "footer", "header", "ol", "ul", "blockquote": w := New[core.Frame](ctx) + newWidget = w ctx.NewParent = w switch tag { case "body": @@ -109,21 +112,21 @@ func handleElement(ctx *Context) { }) } case "button": - New[core.Button](ctx).SetText(ExtractText(ctx)) + newWidget = New[core.Button](ctx).SetText(ExtractText(ctx)) case "h1": - handleText(ctx).SetType(core.TextDisplaySmall) + newWidget = handleText(ctx, tag).SetType(core.TextDisplaySmall) case "h2": - handleText(ctx).SetType(core.TextHeadlineMedium) + newWidget = handleText(ctx, tag).SetType(core.TextHeadlineMedium) case "h3": - handleText(ctx).SetType(core.TextTitleLarge) + newWidget = handleText(ctx, tag).SetType(core.TextTitleLarge) case "h4": - handleText(ctx).SetType(core.TextTitleMedium) + newWidget = handleText(ctx, tag).SetType(core.TextTitleMedium) case "h5": - handleText(ctx).SetType(core.TextTitleSmall) + newWidget = handleText(ctx, tag).SetType(core.TextTitleSmall) case "h6": - handleText(ctx).SetType(core.TextLabelSmall) + newWidget = handleText(ctx, tag).SetType(core.TextLabelSmall) case "p": - handleText(ctx) + newWidget = handleText(ctx, tag) case "pre": hasCode := ctx.Node.FirstChild != nil && ctx.Node.FirstChild.Data == "code" if hasCode { @@ -154,6 +157,7 @@ func handleElement(ctx *Context) { } parent = ed.Parent } + newWidget = ed ctx.Node = codeEl if lang != "" { ed.Lines.SetFileExt(lang) @@ -192,7 +196,8 @@ func handleElement(ctx *Context) { }) } } else { - handleText(ctx).Styler(func(s *styles.Style) { + newWidget = handleText(ctx, tag) + newWidget.AsWidget().Styler(func(s *styles.Style) { s.Text.WhiteSpace = text.WhiteSpacePreWrap }) } @@ -265,13 +270,17 @@ func handleElement(ctx *Context) { svg.SetTooltip(alt) if pid != "" { svg.SetName(pid) + svg.SetProperty("tag", "figure") } + newWidget = svg } else { img = New[core.Image](ctx) img.SetTooltip(alt) if pid != "" { img.SetName(pid) + img.SetProperty("tag", "figure") } + newWidget = img } go func() { @@ -303,33 +312,34 @@ func handleElement(ctx *Context) { switch ityp { case "number": fval := float32(errors.Log1(strconv.ParseFloat(val, 32))) - New[core.Spinner](ctx).SetValue(fval) + newWidget = New[core.Spinner](ctx).SetValue(fval) case "checkbox": - New[core.Switch](ctx).SetType(core.SwitchCheckbox). + newWidget = New[core.Switch](ctx).SetType(core.SwitchCheckbox). SetState(HasAttr(ctx.Node, "checked"), states.Checked) case "radio": - New[core.Switch](ctx).SetType(core.SwitchRadioButton). + newWidget = New[core.Switch](ctx).SetType(core.SwitchRadioButton). SetState(HasAttr(ctx.Node, "checked"), states.Checked) case "range": fval := float32(errors.Log1(strconv.ParseFloat(val, 32))) - New[core.Slider](ctx).SetValue(fval) + newWidget = New[core.Slider](ctx).SetValue(fval) case "button", "submit": - New[core.Button](ctx).SetText(val) + newWidget = New[core.Button](ctx).SetText(val) case "color": - core.Bind(val, New[core.ColorButton](ctx)) + newWidget = core.Bind(val, New[core.ColorButton](ctx)) case "datetime": - core.Bind(val, New[core.TimeInput](ctx)) + newWidget = core.Bind(val, New[core.TimeInput](ctx)) case "file": - core.Bind(val, New[core.FileButton](ctx)) + newWidget = core.Bind(val, New[core.FileButton](ctx)) default: - New[core.TextField](ctx).SetText(val) + newWidget = New[core.TextField](ctx).SetText(val) } case "textarea": buf := lines.NewLines() buf.SetText([]byte(ExtractText(ctx))) - New[textcore.Editor](ctx).SetLines(buf) + newWidget = New[textcore.Editor](ctx).SetLines(buf) case "table": w := New[core.Frame](ctx) + newWidget = w ctx.NewParent = w ctx.TableParent = w ctx.firstRow = true @@ -348,10 +358,11 @@ func handleElement(ctx *Context) { cols++ ctx.TableParent.SetProperty("cols", cols) } - tx := handleText(ctx) + tx := handleText(ctx, tag) if tx.Parent == nil { // if empty we need a real empty text to keep structure tx = New[core.Text](ctx) } + newWidget = tx // fmt.Println(tag, "val:", tx.Text) if tag == "th" { tx.Styler(func(s *styles.Style) { @@ -376,6 +387,9 @@ func handleElement(ctx *Context) { default: ctx.NewParent = ctx.Parent() } + if newWidget != nil { + ctx.handleWidget(newWidget) + } } func (ctx *Context) textStyler(s *styles.Style) { @@ -391,7 +405,7 @@ func (ctx *Context) textStyler(s *styles.Style) { // handleText creates a new [core.Text] from the given information, setting the text and // the text click function so that URLs are opened according to [Context.OpenURL]. -func handleText(ctx *Context) *core.Text { +func handleText(ctx *Context, tag string) *core.Text { tx, _ := handleTextExclude(ctx) return tx } diff --git a/text/paginate/layout.go b/text/paginate/layout.go index c870027bd7..eee2310b49 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -5,6 +5,8 @@ package paginate import ( + "fmt" + "cogentcore.org/core/core" "cogentcore.org/core/math32" "cogentcore.org/core/tree" @@ -14,7 +16,11 @@ import ( func (p *pager) pagify(its []*item) [][]*item { widg := core.AsWidget size := func(it *item) float32 { - return widg(it.w).Geom.Size.Actual.Total.Y + it.gap.Y + ih := widg(it.w).Geom.Size.Actual.Total.Y + if ih == 0 { + fmt.Println("zero height", ih, it.w) + } + return ih + it.gap.Y } maxY := p.opts.BodyDots.Y @@ -25,8 +31,12 @@ func (p *pager) pagify(its []*item) [][]*item { n := len(its) for i, ci := range its { cw := widg(ci.w) + // fmt.Println(cw) brk := cw.Property("paginate-break") != nil nobrk := cw.Property("paginate-no-break-after") != nil + if nobrk { + fmt.Println("no break:", cw) + } sz := size(ci) over := ht+sz > maxY if !over && nobrk { @@ -38,12 +48,15 @@ func (p *pager) pagify(its []*item) [][]*item { } } if brk || over { + ht = 0 if !brk && len(cpg) == 0 { // no blank pages! cpg = append(cpg, ci) + pgs = append(pgs, cpg) + cpg = nil + continue } pgs = append(pgs, cpg) cpg = nil - ht = 0 } ht += sz cpg = append(cpg, ci) diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index e98245eda8..2dc60a59e3 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -6,6 +6,7 @@ package paginate import ( "cogentcore.org/core/core" + "cogentcore.org/core/math32" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" @@ -58,6 +59,8 @@ func (p *pager) preRender() { }) p.opts.Title(tf, p.opts) p.ins = append([]core.Widget{tf.This.(core.Widget)}, p.ins...) + tf.StyleTree() + tf.LayoutFrame(math32.FromPoint(tf.Scene.SceneGeom.Size)) } for _, in := range p.ins { iw := core.AsWidget(in) From 3d3a82044114a99e72ae1d53cba0c478b3df3bd8 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 17:32:28 +0200 Subject: [PATCH 30/99] pdf: move content handlers into handlers and cleanup; figure captions now generated not in html but rather in widgetHandler which is simpler and finally allows for arbitrary html formatting inside the caption. --- content/content.go | 98 +------------------ content/examples/basic/content/button.md | 1 + content/examples/basic/content/home.md | 13 ++- content/handlers.go | 115 +++++++++++++++++++++++ htmlcore/context.go | 61 +++++++----- htmlcore/handler.go | 15 ++- text/paginate/layout.go | 3 - 7 files changed, 176 insertions(+), 130 deletions(-) diff --git a/content/content.go b/content/content.go index 7f0ae108ae..41ca11306f 100644 --- a/content/content.go +++ b/content/content.go @@ -12,7 +12,6 @@ import ( "bytes" "cmp" "fmt" - "io" "io/fs" "net/http" "os" @@ -21,7 +20,6 @@ import ( "strconv" "strings" - "github.com/gomarkdown/markdown/ast" "golang.org/x/exp/maps" "cogentcore.org/core/base/errors" @@ -33,7 +31,6 @@ import ( "cogentcore.org/core/htmlcore" "cogentcore.org/core/math32" "cogentcore.org/core/styles" - "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/units" "cogentcore.org/core/system" "cogentcore.org/core/text/csl" @@ -144,99 +141,8 @@ func (ct *Content) Init() { errors.Log(ct.embedPage(ctx)) return true } - ct.Context.AttributeHandlers["id"] = func(ctx *htmlcore.Context, w io.Writer, node ast.Node, entering bool, tag, value string) bool { - if ct.currentPage == nil { - return false - } - lbl := ct.currentPage.SpecialLabel(value) - ch := node.GetChildren() - if len(ch) == 2 { // image or table - if entering { - sty := htmlcore.MDGetAttr(node, "style") - if sty != "" { - if img, ok := ch[1].(*ast.Image); ok { - htmlcore.MDSetAttr(img, "style", sty) - delete(node.AsContainer().Attribute.Attrs, "style") - } - } - return false - } - cp := "\n

" + lbl + ":" - if img, ok := ch[1].(*ast.Image); ok { - // fmt.Printf("Image: %s\n", string(img.Destination)) - // fmt.Printf("Image: %#v\n", img) - nc := len(img.Children) - if nc > 0 { - if txt, ok := img.Children[0].(*ast.Text); ok { - // fmt.Printf("text: %s\n", string(txt.Literal)) // not formatted! - cp += " " + string(txt.Literal) // todo: not formatted! - } - } - } else { - title := htmlcore.MDGetAttr(node, "title") - if title != "" { - cp += " " + title - } - } - cp += "

\n" - w.Write([]byte(cp)) - } else if entering { - cp := "\n" + lbl + ":" - title := htmlcore.MDGetAttr(node, "title") - if title != "" { - cp += " " + title - } - cp += "\n" - w.Write([]byte(cp)) - // fmt.Println("id:", value, lbl) - // fmt.Printf("%#v\n", node) - } - return false - } - ct.Context.AddWidgetHandler(func(w core.Widget) { - switch x := w.(type) { - case *core.Text: - hdr := false - if t, ok := x.Properties["tag"]; ok { - if len(t.(string)) > 0 && (t.(string))[0] == 'h' { - hdr = true - } - } - x.Styler(func(s *styles.Style) { - s.Max.X.In(8) // big enough to not constrain PDF render - if hdr { - x.SetProperty("paginate-no-break-after", true) - } - }) - case *core.Image: - fig := false - if t, ok := x.Properties["tag"]; ok { - fig = (t.(string) == "figure") // images with id set are considered images, center - } - x.Styler(func(s *styles.Style) { - s.SetAbilities(true, abilities.Clickable, abilities.DoubleClickable) - s.Overflow.Set(styles.OverflowAuto) - if fig { - s.Align.Self = styles.Center - } - }) - x.OnDoubleClick(func(e events.Event) { - d := core.NewBody("Image") - core.NewImage(d).SetImage(x.Image) - d.RunWindowDialog(x) - }) - case *core.Frame: - table := false - if t, ok := x.Properties["tag"]; ok { - table = (t.(string) == "table") - } - if table { - x.Styler(func(s *styles.Style) { - s.Align.Self = styles.Center - }) - } - } - }) + ct.Context.AttributeHandlers["id"] = ct.htmlIDAttributeHandler + ct.Context.AddWidgetHandler(ct.widgetHandler) ct.Maker(func(p *tree.Plan) { if ct.currentPage == nil { diff --git a/content/examples/basic/content/button.md b/content/examples/basic/content/button.md index a0110b43ae..c8bed34f37 100644 --- a/content/examples/basic/content/button.md +++ b/content/examples/basic/content/button.md @@ -11,6 +11,7 @@ A **button** is a [[widget]] that a user can click on to trigger a described act Buttons can do math? $x = y^2$ and even display math: +{id="eq_math" title="Math demo"} $$ y = \frac{1}{N} \left( \sum_{i=0}^{100} \frac{f(x^2)}{\sum x^2} \right) $$ diff --git a/content/examples/basic/content/home.md b/content/examples/basic/content/home.md index e89c7206d4..73b5e9de8d 100644 --- a/content/examples/basic/content/home.md +++ b/content/examples/basic/content/home.md @@ -5,6 +5,17 @@ URL = "" Welcome to **Cogent Content**, built out of [[widget]]s. {id="image_gopher" style="height:10em"} -![Image of the Go Gopher](https://miro.medium.com/v2/resize:fit:1000/0*YISbBYJg5hkJGcQd.png) +![Image of the Go gopher: so cute!](https://miro.medium.com/v2/resize:fit:1000/0*YISbBYJg5hkJGcQd.png) +There are some parameters we need to know: + +{id="table_conduction" title="Axonal conduction delays"} +| Pathway | Minimum | Mean or Median | +|------------------|-----------|------------------------------| +| Corticocortical | 2 ms | 2.3 (magno visual) -- ~10 ms | +| Corticothalamic | 2 ms | ~10 ms | +| Thalamocortical | 0.5 ms | ~1 ms | +| Collosal | ~2 ms | ~10 ms | + +See [[#image_gopher]] for a cute gopher, and [[#table_conduction]] for some interesting facts. diff --git a/content/handlers.go b/content/handlers.go index 2a1e1fe90f..e90dc0eb9d 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -6,6 +6,7 @@ package content import ( "fmt" + "io" "strings" "cogentcore.org/core/base/errors" @@ -14,13 +15,127 @@ import ( "cogentcore.org/core/content/bcontent" "cogentcore.org/core/core" "cogentcore.org/core/events" + "cogentcore.org/core/htmlcore" "cogentcore.org/core/math32" + "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" "cogentcore.org/core/text/csl" "cogentcore.org/core/tree" + "github.com/gomarkdown/markdown/ast" ) +// handles the id attribute in htmlcore -- deals with all non-image id cases +func (ct *Content) htmlIDAttributeHandler(ctx *htmlcore.Context, w io.Writer, node ast.Node, entering bool, tag, value string) bool { + if ct.currentPage == nil { + return false + } + lbl := ct.currentPage.SpecialLabel(value) + ch := node.GetChildren() + if len(ch) == 2 { // image or table + if entering { + return false + } + if _, ok := ch[1].(*ast.Image); ok { + return false + } + cp := "\n

" + lbl + ":" + title := htmlcore.MDGetAttr(node, "title") + if title != "" { + cp += " " + title + } + cp += "

\n" + w.Write([]byte(cp)) + return false + } + if entering { + cp := "\n" + lbl + ":" + title := htmlcore.MDGetAttr(node, "title") + if title != "" { + cp += " " + title + } + cp += "\n" + w.Write([]byte(cp)) + // fmt.Println("id:", value, lbl) + // fmt.Printf("%#v\n", node) + } + return false +} + +// widgetHandler is htmlcore widget handler for adding our own actions etc. +func (ct *Content) widgetHandler(w core.Widget) { + switch x := w.(type) { + case *core.Text: + hdr := false + if t, ok := x.Properties["tag"]; ok { + if len(t.(string)) > 0 && (t.(string))[0] == 'h' { + hdr = true + } + } + x.Styler(func(s *styles.Style) { + s.Max.X.In(8) // big enough to not constrain PDF render + if hdr { + x.SetProperty("paginate-no-break-after", true) + } + }) + case *core.Image: + ct.widgetHandlerFigure(w) + x.OnDoubleClick(func(e events.Event) { + d := core.NewBody("Image") + core.NewImage(d).SetImage(x.Image) + d.RunWindowDialog(x) + }) + case *core.SVG: + ct.widgetHandlerFigure(w) + x.OnDoubleClick(func(e events.Event) { + d := core.NewBody("SVG") + sv := core.NewSVG(d) + sv.SVG = x.SVG + d.RunWindowDialog(x) + }) + case *core.Frame: + table := false + if t, ok := x.Properties["tag"]; ok { + table = (t.(string) == "table") + } + if table { + x.Styler(func(s *styles.Style) { + s.Align.Self = styles.Center + }) + } + } +} + +func (ct *Content) widgetHandlerFigure(w core.Widget) { + wb := w.AsWidget() + fig := false + alt := "" + id := "" + if p, ok := wb.Properties["alt"]; ok { + alt = p.(string) + } + if p, ok := wb.Properties["id"]; ok { + id = p.(string) + } + if alt != "" && id != "" { + fig = true + } + wb.Styler(func(s *styles.Style) { + s.SetAbilities(true, abilities.Clickable, abilities.DoubleClickable) + s.Overflow.Set(styles.OverflowAuto) + if fig { + s.Align.Self = styles.Center + } + }) + if !fig { + return + } + lbl := ct.currentPage.SpecialLabel(id) + lbf := "" + lbl + ": " + cp := core.NewText(wb.Parent).SetText(lbf + alt + "

") + cp.SetProperty("paginate-no-break-before", true) +} + // citeWikilink processes citation links, which start with @ func (ct *Content) citeWikilink(text string) (url string, label string) { if len(text) == 0 || text[0] != '@' { // @CiteKey reference citations diff --git a/htmlcore/context.go b/htmlcore/context.go index 229dacf546..8b73b81abb 100644 --- a/htmlcore/context.go +++ b/htmlcore/context.go @@ -147,34 +147,30 @@ func (c *Context) config(w core.Widget) { case "id": wb.SetName(attr.Val) case "style": - // our CSS parser is strict about semicolons, but - // they aren't needed in normal inline styles in HTML - if !strings.HasSuffix(attr.Val, ";") { - attr.Val += ";" - } - decls, err := parser.ParseDeclarations(attr.Val) - if errors.Log(err) != nil { - continue - } - rule := &css.Rule{Declarations: decls} - if c.styles == nil { - c.styles = map[*html.Node][]*css.Rule{} - } - c.styles[c.Node] = append(c.styles[c.Node], rule) + c.setStyleAttr(c.Node, attr.Val) default: wb.SetProperty(attr.Key, attr.Val) } } wb.SetProperty("tag", c.Node.Data) // this is needed by handleWidget in general - rules := c.styles[c.Node] - wb.Styler(func(s *styles.Style) { - for _, rule := range rules { - for _, decl := range rule.Declarations { - // TODO(kai/styproperties): parent style and context - s.FromProperty(s, decl.Property, decl.Value, colors.BaseContext(colors.ToUniform(s.Color))) - } - } - }) +} + +func (c *Context) setStyleAttr(node *html.Node, style string) error { + // our CSS parser is strict about semicolons, but + // they aren't needed in normal inline styles in HTML + if !strings.HasSuffix(style, ";") { + style += ";" + } + decls, err := parser.ParseDeclarations(style) + if errors.Log(err) != nil { + return err + } + rule := &css.Rule{Declarations: decls} + if c.styles == nil { + c.styles = map[*html.Node][]*css.Rule{} + } + c.styles[c.Node] = append(c.styles[c.Node], rule) + return nil } // InlineParent returns the current parent widget that inline @@ -257,9 +253,24 @@ func (c *Context) AddWidgetHandler(f func(w core.Widget)) { c.WidgetHandlers = append(c.WidgetHandlers, f) } -// handleWidget calls WidgetHandlers functions on given widget, +func (c *Context) applyStyleRules(node *html.Node, w core.Widget) { + wb := w.AsWidget() + rules := c.styles[c.Node] + wb.Styler(func(s *styles.Style) { + for _, rule := range rules { + for _, decl := range rule.Declarations { + // TODO(kai/styproperties): parent style and context + s.FromProperty(s, decl.Property, decl.Value, colors.BaseContext(colors.ToUniform(s.Color))) + } + } + }) +} + +// handleWidget applies accumulated style rules, +// and calls WidgetHandlers functions on given widget, // in order added so last one has override priority. -func (c *Context) handleWidget(w core.Widget) { +func (c *Context) handleWidget(node *html.Node, w core.Widget) { + c.applyStyleRules(node, w) for _, f := range c.WidgetHandlers { f(w) } diff --git a/htmlcore/handler.go b/htmlcore/handler.go index 195e620a7e..8fc92e6f1c 100644 --- a/htmlcore/handler.go +++ b/htmlcore/handler.go @@ -258,10 +258,15 @@ func handleElement(ctx *Context) { n := ctx.Node src := GetAttr(n, "src") alt := GetAttr(n, "alt") + style := "" pid := "" - if ctx.BlockParent != nil { + if ctx.BlockParent != nil { // these attributes get put on a block parent element + style = GetAttr(n.Parent, "style") pid = GetAttr(n.Parent, "id") } + if style != "" { + ctx.setStyleAttr(n, style) + } // Can be either image or svg. var img *core.Image var svg *core.SVG @@ -270,7 +275,7 @@ func handleElement(ctx *Context) { svg.SetTooltip(alt) if pid != "" { svg.SetName(pid) - svg.SetProperty("tag", "figure") + svg.SetProperty("id", pid) } newWidget = svg } else { @@ -278,7 +283,7 @@ func handleElement(ctx *Context) { img.SetTooltip(alt) if pid != "" { img.SetName(pid) - img.SetProperty("tag", "figure") + img.SetProperty("id", pid) } newWidget = img } @@ -347,7 +352,7 @@ func handleElement(ctx *Context) { w.Styler(func(s *styles.Style) { s.Display = styles.Grid s.Overflow.X = styles.OverflowAuto - s.Grow.Set(1, 1) + s.Grow.Set(1, 0) s.Columns = w.Property("cols").(int) s.Gap.X.Dp(core.ConstantSpacing(6)) s.Justify.Content = styles.Center @@ -388,7 +393,7 @@ func handleElement(ctx *Context) { ctx.NewParent = ctx.Parent() } if newWidget != nil { - ctx.handleWidget(newWidget) + ctx.handleWidget(ctx.Node, newWidget) } } diff --git a/text/paginate/layout.go b/text/paginate/layout.go index eee2310b49..4a288851fe 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -34,9 +34,6 @@ func (p *pager) pagify(its []*item) [][]*item { // fmt.Println(cw) brk := cw.Property("paginate-break") != nil nobrk := cw.Property("paginate-no-break-after") != nil - if nobrk { - fmt.Println("no break:", cw) - } sz := size(ci) over := ht+sz > maxY if !over && nobrk { From 0f0b1c84d0a666f19bb37f23fac81fbf20ea2670 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 17:52:58 +0200 Subject: [PATCH 31/99] pdf: handle text improvements; block for figures to keep together -- need that for tables etc. --- content/handlers.go | 19 +++++++++++++++++-- htmlcore/handler.go | 16 ++-------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/content/handlers.go b/content/handlers.go index e90dc0eb9d..7688613d3e 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -20,6 +20,7 @@ import ( "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" + "cogentcore.org/core/styles/units" "cogentcore.org/core/text/csl" "cogentcore.org/core/tree" "github.com/gomarkdown/markdown/ast" @@ -73,6 +74,8 @@ func (ct *Content) widgetHandler(w core.Widget) { } } x.Styler(func(s *styles.Style) { + s.Margin.SetVertical(units.Em(core.ConstantSpacing(0.25))) + s.Font.Size.Value *= core.AppearanceSettings.DocsFontSize / 100 s.Max.X.In(8) // big enough to not constrain PDF render if hdr { x.SetProperty("paginate-no-break-after", true) @@ -130,10 +133,22 @@ func (ct *Content) widgetHandlerFigure(w core.Widget) { if !fig { return } + fr := core.NewFrame(wb.Parent) + fr.Styler(func(s *styles.Style) { + s.Grow.Set(1, 0) + s.Direction = styles.Column + }) + fr.SetProperty("paginate-block", true) // no split + fr.SetProperty("id", id) + tree.MoveToParent(w, fr) + fr.SetName(id) lbl := ct.currentPage.SpecialLabel(id) lbf := "" + lbl + ": " - cp := core.NewText(wb.Parent).SetText(lbf + alt + "

") - cp.SetProperty("paginate-no-break-before", true) + tx := core.NewText(fr).SetText(lbf + alt + "

") + tx.Styler(func(s *styles.Style) { + s.Max.X.In(8) // big enough to not constrain PDF render + s.Font.Size.Value *= core.AppearanceSettings.DocsFontSize / 100 + }) } // citeWikilink processes citation links, which start with @ diff --git a/htmlcore/handler.go b/htmlcore/handler.go index 8fc92e6f1c..4c56d6e7a9 100644 --- a/htmlcore/handler.go +++ b/htmlcore/handler.go @@ -19,7 +19,6 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/styles" "cogentcore.org/core/styles/states" - "cogentcore.org/core/styles/units" "cogentcore.org/core/text/lines" "cogentcore.org/core/text/rich" "cogentcore.org/core/text/text" @@ -213,6 +212,7 @@ func handleElement(ctx *Context) { ctx.Node = ctx.Node.FirstChild.NextSibling } text, sublist := handleTextExclude(ctx, "ol", "ul") // exclude other lists + newWidget = text start := "" if pw, ok := text.Parent.(core.Widget); ok { pwt := pw.AsTree() @@ -243,6 +243,7 @@ func handleElement(ctx *Context) { } txt, psub := handleTextExclude(ctx, "ol", "ul") txt.SetText(txt.Text) + ctx.handleWidget(cnode, txt) if psub != nil { if psub != sublist { readHTMLNode(ctx, ctx.Parent(), psub) @@ -397,17 +398,6 @@ func handleElement(ctx *Context) { } } -func (ctx *Context) textStyler(s *styles.Style) { - s.Margin.SetVertical(units.Em(core.ConstantSpacing(0.25))) - s.Font.Size.Value *= core.AppearanceSettings.DocsFontSize / 100 - // TODO: it would be ideal for htmlcore to automatically save a scale factor - // in general and for each domain, that is applied only to page content - // scale := float32(1.2) - // s.Font.Size.Value *= scale - // s.Text.LineHeight.Value *= scale - // s.Text.LetterSpacing.Value *= scale -} - // handleText creates a new [core.Text] from the given information, setting the text and // the text click function so that URLs are opened according to [Context.OpenURL]. func handleText(ctx *Context, tag string) *core.Text { @@ -426,7 +416,6 @@ func handleTextExclude(ctx *Context, excludeSubs ...string) (*core.Text, *html.N return core.NewText(), excl } tx := New[core.Text](ctx).SetText(et) - tx.Styler(ctx.textStyler) tx.HandleTextClick(func(tl *rich.Hyperlink) { ctx.OpenURL(tl.URL) }) @@ -442,7 +431,6 @@ func handleTextTag(ctx *Context) *core.Text { start, end := nodeString(ctx.Node) str := start + ExtractText(ctx) + end tx := New[core.Text](ctx).SetText(str) - tx.Styler(ctx.textStyler) tx.HandleTextClick(func(tl *rich.Hyperlink) { ctx.OpenURL(tl.URL) }) From a031688a8ac777c309115de51b1fae69d4e55592 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 21:52:48 +0200 Subject: [PATCH 32/99] pdf: move pre handler with BindTextEditor into content -- did not belong in htmlcore, which is just the basic handler without any of the fancy interactive stuff. moved all the pre elements into a frame, so pdf render is blocked. neuron-channels page is _amazing_ with all the plots etc. --- content/content.go | 1 + content/handlers.go | 191 +++++++++++++----- htmlcore/context.go | 1 + htmlcore/handler.go | 92 ++------- htmlcore/html.go | 2 +- .../cogentcore_org-core-content.go | 6 +- .../cogentcore_org-core-htmlcore.go | 2 +- yaegicore/yaegicore.go | 5 +- 8 files changed, 177 insertions(+), 123 deletions(-) diff --git a/content/content.go b/content/content.go index 41ca11306f..edc4bc1eb4 100644 --- a/content/content.go +++ b/content/content.go @@ -141,6 +141,7 @@ func (ct *Content) Init() { errors.Log(ct.embedPage(ctx)) return true } + ct.Context.ElementHandlers["pre"] = ct.htmlPreHandler ct.Context.AttributeHandlers["id"] = ct.htmlIDAttributeHandler ct.Context.AddWidgetHandler(ct.widgetHandler) diff --git a/content/handlers.go b/content/handlers.go index 7688613d3e..0f94d54ad0 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -12,6 +12,7 @@ import ( "cogentcore.org/core/base/errors" "cogentcore.org/core/base/labels" "cogentcore.org/core/base/strcase" + "cogentcore.org/core/colors" "cogentcore.org/core/content/bcontent" "cogentcore.org/core/core" "cogentcore.org/core/events" @@ -22,31 +23,31 @@ import ( "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/csl" + "cogentcore.org/core/text/textcore" "cogentcore.org/core/tree" "github.com/gomarkdown/markdown/ast" ) -// handles the id attribute in htmlcore -- deals with all non-image id cases +// BindTextEditor is a function set to [cogentcore.org/core/yaegicore.BindTextEditor] +// when importing yaegicore, which provides interactive editing functionality for Go +// code blocks in text editors. +var BindTextEditor func(ed *textcore.Editor, parent *core.Frame, language string) + +// * add after-first-line indent (what is official name?) to text style and implement it -- need for li etc +// * paginate uses left margins +// * not able to click off shading of figures etc when clicking link + +// handles the id attribute in htmlcore: needed for equation case func (ct *Content) htmlIDAttributeHandler(ctx *htmlcore.Context, w io.Writer, node ast.Node, entering bool, tag, value string) bool { if ct.currentPage == nil { return false } lbl := ct.currentPage.SpecialLabel(value) + if lbl == "" { + return false + } ch := node.GetChildren() if len(ch) == 2 { // image or table - if entering { - return false - } - if _, ok := ch[1].(*ast.Image); ok { - return false - } - cp := "\n

" + lbl + ":" - title := htmlcore.MDGetAttr(node, "title") - if title != "" { - cp += " " + title - } - cp += "

\n" - w.Write([]byte(cp)) return false } if entering { @@ -63,16 +64,97 @@ func (ct *Content) htmlIDAttributeHandler(ctx *htmlcore.Context, w io.Writer, no return false } +func (ct *Content) htmlPreHandler(ctx *htmlcore.Context) bool { + hasCode := ctx.Node.FirstChild != nil && ctx.Node.FirstChild.Data == "code" + if !hasCode { + return false + } + codeEl := ctx.Node.FirstChild + collapsed := htmlcore.GetAttr(codeEl, "collapsed") + lang := htmlcore.GetLanguage(htmlcore.GetAttr(codeEl, "class")) + id := htmlcore.GetAttr(codeEl, "id") + title := htmlcore.GetAttr(codeEl, "title") + var ed *textcore.Editor + parent := ctx.Parent().AsWidget() + fr := core.NewFrame(parent.This) + fr.Styler(func(s *styles.Style) { + s.Grow.Set(1, 0) + s.Direction = styles.Column + }) + fr.SetProperty("paginate-block", true) // no split + if id != "" { + fr.SetProperty("id", id) + fr.SetName(id) + tree.MoveToParent(parent.Children[parent.NumChildren()-2], fr) // get title text + } + if collapsed != "" { + cl := core.NewCollapser(fr) + core.NewText(cl.Summary).SetText("Code").SetText(title) + ed = textcore.NewEditor(cl.Details) + if collapsed == "false" || collapsed == "-" { + cl.Open = true + } + } else { + ed = textcore.NewEditor(fr) + } + ctx.Node = codeEl + if lang != "" { + ed.Lines.SetFileExt(lang) + } + ed.Lines.SetString(htmlcore.ExtractText(ctx)) + if BindTextEditor != nil && (lang == "Go" || lang == "Goal") { + ed.Lines.SpacesToTabs(0, ed.Lines.NumLines()) // Go uses tabs + parFrame := core.NewFrame(fr) + parFrame.Styler(func(s *styles.Style) { + s.Direction = styles.Column + s.Grow.Set(1, 0) + }) + // we inherit our Grow.Y from our first child so that + // elements that want to grow can do so + parFrame.SetOnChildAdded(func(n tree.Node) { + if _, ok := n.(*core.Body); ok { // Body should not grow + return + } + wb := core.AsWidget(n) + if wb.IndexInParent() != 0 { + return + } + wb.FinalStyler(func(s *styles.Style) { + parFrame.Styles.Grow.Y = s.Grow.Y + }) + }) + BindTextEditor(ed, parFrame, lang) + } else { + ed.SetReadOnly(true) + ed.Lines.Settings.LineNumbers = false + ed.Styler(func(s *styles.Style) { + s.Border.Width.Zero() + s.MaxBorder.Width.Zero() + s.StateLayer = 0 + s.Background = colors.Scheme.SurfaceContainer + }) + } + return true +} + // widgetHandler is htmlcore widget handler for adding our own actions etc. func (ct *Content) widgetHandler(w core.Widget) { + tag := "" + id := "" + title := "" + wb := w.AsWidget() + if t, ok := wb.Properties["tag"]; ok { + tag = t.(string) + } + if t, ok := wb.Properties["id"]; ok { + id = t.(string) + } + if t, ok := wb.Properties["title"]; ok { + title = t.(string) + } switch x := w.(type) { case *core.Text: - hdr := false - if t, ok := x.Properties["tag"]; ok { - if len(t.(string)) > 0 && (t.(string))[0] == 'h' { - hdr = true - } - } + hdr := len(tag) > 0 && tag[0] == 'h' x.Styler(func(s *styles.Style) { s.Margin.SetVertical(units.Em(core.ConstantSpacing(0.25))) s.Font.Size.Value *= core.AppearanceSettings.DocsFontSize / 100 @@ -82,14 +164,14 @@ func (ct *Content) widgetHandler(w core.Widget) { } }) case *core.Image: - ct.widgetHandlerFigure(w) + ct.widgetHandlerFigure(w, id) x.OnDoubleClick(func(e events.Event) { d := core.NewBody("Image") core.NewImage(d).SetImage(x.Image) d.RunWindowDialog(x) }) case *core.SVG: - ct.widgetHandlerFigure(w) + ct.widgetHandlerFigure(w, id) x.OnDoubleClick(func(e events.Event) { d := core.NewBody("SVG") sv := core.NewSVG(d) @@ -97,29 +179,57 @@ func (ct *Content) widgetHandler(w core.Widget) { d.RunWindowDialog(x) }) case *core.Frame: - table := false - if t, ok := x.Properties["tag"]; ok { - table = (t.(string) == "table") - } - if table { + switch tag { + case "table": x.Styler(func(s *styles.Style) { s.Align.Self = styles.Center }) + if id == "" { + break + } + lbl := ct.currentPage.SpecialLabel(id) + cp := "" + lbl + ":" + if title != "" { + cp += " " + title + } + ct.moveToBlockFrame(w, id, cp, true) } } } -func (ct *Content) widgetHandlerFigure(w core.Widget) { +// moveToBlockFrame moves given widget into a block frame with given text +// widget either at the top or bottom of the new frame. +func (ct *Content) moveToBlockFrame(w core.Widget, id, txt string, top bool) { + wb := w.AsWidget() + fr := core.NewFrame(wb.Parent) + fr.Styler(func(s *styles.Style) { + s.Grow.Set(1, 0) + s.Direction = styles.Column + }) + fr.SetProperty("paginate-block", true) // no split + fr.SetProperty("id", id) + fr.SetName(id) + var tx *core.Text + if top { + tx = core.NewText(fr).SetText(txt) + } + tree.MoveToParent(w, fr) + if !top { + tx = core.NewText(fr).SetText(txt) + } + tx.Styler(func(s *styles.Style) { + s.Max.X.In(8) + s.Font.Size.Value *= core.AppearanceSettings.DocsFontSize / 100 + }) +} + +func (ct *Content) widgetHandlerFigure(w core.Widget, id string) { wb := w.AsWidget() fig := false alt := "" - id := "" if p, ok := wb.Properties["alt"]; ok { alt = p.(string) } - if p, ok := wb.Properties["id"]; ok { - id = p.(string) - } if alt != "" && id != "" { fig = true } @@ -133,22 +243,9 @@ func (ct *Content) widgetHandlerFigure(w core.Widget) { if !fig { return } - fr := core.NewFrame(wb.Parent) - fr.Styler(func(s *styles.Style) { - s.Grow.Set(1, 0) - s.Direction = styles.Column - }) - fr.SetProperty("paginate-block", true) // no split - fr.SetProperty("id", id) - tree.MoveToParent(w, fr) - fr.SetName(id) lbl := ct.currentPage.SpecialLabel(id) - lbf := "" + lbl + ": " - tx := core.NewText(fr).SetText(lbf + alt + "

") - tx.Styler(func(s *styles.Style) { - s.Max.X.In(8) // big enough to not constrain PDF render - s.Font.Size.Value *= core.AppearanceSettings.DocsFontSize / 100 - }) + lbf := "" + lbl + ": " + alt + "

" + ct.moveToBlockFrame(w, id, lbf, false) } // citeWikilink processes citation links, which start with @ diff --git a/htmlcore/context.go b/htmlcore/context.go index 8b73b81abb..f1ed184cbf 100644 --- a/htmlcore/context.go +++ b/htmlcore/context.go @@ -146,6 +146,7 @@ func (c *Context) config(w core.Widget) { switch attr.Key { case "id": wb.SetName(attr.Val) + wb.SetProperty("id", attr.Val) case "style": c.setStyleAttr(c.Node, attr.Val) default: diff --git a/htmlcore/handler.go b/htmlcore/handler.go index 4c56d6e7a9..bb819f9f56 100644 --- a/htmlcore/handler.go +++ b/htmlcore/handler.go @@ -53,6 +53,12 @@ func handleElement(ctx *Context) { return } + pid := "" + pstyle := "" + if ctx.BlockParent != nil { // these attributes get put on a block parent element + pstyle = GetAttr(ctx.Node.Parent, "style") + pid = GetAttr(ctx.Node.Parent, "id") + } var newWidget core.Widget switch tag { @@ -130,31 +136,12 @@ func handleElement(ctx *Context) { hasCode := ctx.Node.FirstChild != nil && ctx.Node.FirstChild.Data == "code" if hasCode { codeEl := ctx.Node.FirstChild - collapsed := GetAttr(codeEl, "collapsed") - lang := getLanguage(GetAttr(codeEl, "class")) + lang := GetLanguage(GetAttr(codeEl, "class")) id := GetAttr(codeEl, "id") var ed *textcore.Editor - var parent tree.Node - if collapsed != "" { - cl := New[core.Collapser](ctx) - summary := core.NewText(cl.Summary).SetText("Code") - if title := GetAttr(codeEl, "title"); title != "" { - summary.SetText(title) - } - ed = textcore.NewEditor(cl.Details) - if id != "" { - cl.Summary.Name = id - } - parent = cl.Parent - if collapsed == "false" || collapsed == "-" { - cl.Open = true - } - } else { - ed = New[textcore.Editor](ctx) - if id != "" { - ed.SetName(id) - } - parent = ed.Parent + ed = New[textcore.Editor](ctx) + if id != "" { + ed.SetName(id) } newWidget = ed ctx.Node = codeEl @@ -162,38 +149,14 @@ func handleElement(ctx *Context) { ed.Lines.SetFileExt(lang) } ed.Lines.SetString(ExtractText(ctx)) - if BindTextEditor != nil && (lang == "Go" || lang == "Goal") { - ed.Lines.SpacesToTabs(0, ed.Lines.NumLines()) // Go uses tabs - parFrame := core.NewFrame(parent) - parFrame.Styler(func(s *styles.Style) { - s.Direction = styles.Column - s.Grow.Set(1, 0) - }) - // we inherit our Grow.Y from our first child so that - // elements that want to grow can do so - parFrame.SetOnChildAdded(func(n tree.Node) { - if _, ok := n.(*core.Body); ok { // Body should not grow - return - } - wb := core.AsWidget(n) - if wb.IndexInParent() != 0 { - return - } - wb.FinalStyler(func(s *styles.Style) { - parFrame.Styles.Grow.Y = s.Grow.Y - }) - }) - BindTextEditor(ed, parFrame, lang) - } else { - ed.SetReadOnly(true) - ed.Lines.Settings.LineNumbers = false - ed.Styler(func(s *styles.Style) { - s.Border.Width.Zero() - s.MaxBorder.Width.Zero() - s.StateLayer = 0 - s.Background = colors.Scheme.SurfaceContainer - }) - } + ed.SetReadOnly(true) + ed.Lines.Settings.LineNumbers = false + ed.Styler(func(s *styles.Style) { + s.Border.Width.Zero() + s.MaxBorder.Width.Zero() + s.StateLayer = 0 + s.Background = colors.Scheme.SurfaceContainer + }) } else { newWidget = handleText(ctx, tag) newWidget.AsWidget().Styler(func(s *styles.Style) { @@ -259,14 +222,8 @@ func handleElement(ctx *Context) { n := ctx.Node src := GetAttr(n, "src") alt := GetAttr(n, "alt") - style := "" - pid := "" - if ctx.BlockParent != nil { // these attributes get put on a block parent element - style = GetAttr(n.Parent, "style") - pid = GetAttr(n.Parent, "id") - } - if style != "" { - ctx.setStyleAttr(n, style) + if pstyle != "" { + ctx.setStyleAttr(n, pstyle) } // Can be either image or svg. var img *core.Image @@ -473,9 +430,9 @@ func HasAttr(n *html.Node, attr string) bool { }) } -// getLanguage returns the 'x' in a `language-x` class from the given +// GetLanguage returns the 'x' in a `language-x` class from the given // string of class(es). -func getLanguage(class string) string { +func GetLanguage(class string) string { fields := strings.Fields(class) for _, field := range fields { if strings.HasPrefix(field, "language-") { @@ -505,8 +462,3 @@ func Get(ctx *Context, url string) (*http.Response, error) { } return resp, nil } - -// BindTextEditor is a function set to [cogentcore.org/core/yaegicore.BindTextEditor] -// when importing yaegicore, which provides interactive editing functionality for Go -// code blocks in text editors. -var BindTextEditor func(ed *textcore.Editor, parent *core.Frame, language string) diff --git a/htmlcore/html.go b/htmlcore/html.go index 08d70405f9..6ec8681a25 100644 --- a/htmlcore/html.go +++ b/htmlcore/html.go @@ -46,7 +46,7 @@ func readHTMLNode(ctx *Context, parent core.Widget, n *html.Node) error { case html.TextNode: str := strings.TrimSpace(n.Data) if str != "" { - New[core.Text](ctx).SetText(str) + ctx.handleWidget(n, New[core.Text](ctx).SetText(str)) } case html.ElementNode: ctx.Node = n diff --git a/yaegicore/coresymbols/cogentcore_org-core-content.go b/yaegicore/coresymbols/cogentcore_org-core-content.go index ccd1ffadae..197406c9c1 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-content.go +++ b/yaegicore/coresymbols/cogentcore_org-core-content.go @@ -10,10 +10,14 @@ import ( func init() { Symbols["cogentcore.org/core/content/content"] = map[string]reflect.Value{ // function, constant and variable definitions + "BindTextEditor": reflect.ValueOf(&content.BindTextEditor).Elem(), "NewContent": reflect.ValueOf(content.NewContent), "NewPageInitFunc": reflect.ValueOf(&content.NewPageInitFunc).Elem(), + "OfflineURL": reflect.ValueOf(&content.OfflineURL).Elem(), + "Settings": reflect.ValueOf(&content.Settings).Elem(), // type definitions - "Content": reflect.ValueOf((*content.Content)(nil)), + "Content": reflect.ValueOf((*content.Content)(nil)), + "SettingsData": reflect.ValueOf((*content.SettingsData)(nil)), } } diff --git a/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go b/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go index e4cbe9d2c8..7fd2b2a36f 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go +++ b/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go @@ -10,11 +10,11 @@ import ( func init() { Symbols["cogentcore.org/core/htmlcore/htmlcore"] = map[string]reflect.Value{ // function, constant and variable definitions - "BindTextEditor": reflect.ValueOf(&htmlcore.BindTextEditor).Elem(), "ExtractText": reflect.ValueOf(htmlcore.ExtractText), "ExtractTextExclude": reflect.ValueOf(htmlcore.ExtractTextExclude), "Get": reflect.ValueOf(htmlcore.Get), "GetAttr": reflect.ValueOf(htmlcore.GetAttr), + "GetLanguage": reflect.ValueOf(htmlcore.GetLanguage), "GetURLFromFS": reflect.ValueOf(htmlcore.GetURLFromFS), "GoDocWikilink": reflect.ValueOf(htmlcore.GoDocWikilink), "HasAttr": reflect.ValueOf(htmlcore.HasAttr), diff --git a/yaegicore/yaegicore.go b/yaegicore/yaegicore.go index 05f57b8188..974a930e42 100644 --- a/yaegicore/yaegicore.go +++ b/yaegicore/yaegicore.go @@ -17,7 +17,6 @@ import ( "cogentcore.org/core/content" "cogentcore.org/core/core" "cogentcore.org/core/events" - "cogentcore.org/core/htmlcore" "cogentcore.org/core/text/textcore" "cogentcore.org/core/yaegicore/basesymbols" "cogentcore.org/core/yaegicore/coresymbols" @@ -51,7 +50,7 @@ type Interpreter interface { } func init() { - htmlcore.BindTextEditor = BindTextEditor + content.BindTextEditor = BindTextEditor content.NewPageInitFunc = ResetGoalInterpreter coresymbols.Symbols["."] = map[string]reflect.Value{} // make "." available for use basesymbols.Symbols["."] = map[string]reflect.Value{} // make "." available for use @@ -103,7 +102,7 @@ func getInterpreter(language string) (in Interpreter, new bool, err error) { // BindTextEditor binds the given text editor to a yaegi interpreter // such that the contents of the text editor are interpreted as code // of the given language, which is run in the context of the given parent widget. -// It is used as the default value of [htmlcore.BindTextEditor]. +// It is used as the default value of [content.BindTextEditor]. func BindTextEditor(ed *textcore.Editor, parent *core.Frame, language string) { oc := func() { in, new, err := getInterpreter(language) From 10c5e18164e3db7ee50b39924ae715723f47e9ea Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 22:39:36 +0200 Subject: [PATCH 33/99] pdf: gap and left padding layout preserved in paginate --- content/buttons.go | 2 +- content/content.go | 1 + content/examples/basic/content/button.md | 21 +++++++++++ htmlcore/handler.go | 2 +- text/paginate/extract.go | 4 +- text/paginate/layout.go | 47 +++++++++++++++++++----- text/paginate/page.go | 15 ++++---- 7 files changed, 71 insertions(+), 21 deletions(-) diff --git a/content/buttons.go b/content/buttons.go index 420ae90234..d751d6649f 100644 --- a/content/buttons.go +++ b/content/buttons.go @@ -77,7 +77,7 @@ func (ct *Content) MakeToolbar(p *tree.Plan) { tree.Add(p, func(w *core.Button) { w.SetText("PDF").SetIcon(icons.PictureAsPdf) w.OnClick(func(e events.Event) { - ct.PagePDF("") + ct.PagePDF("pdfs") }) }) } diff --git a/content/content.go b/content/content.go index edc4bc1eb4..88404eabc0 100644 --- a/content/content.go +++ b/content/content.go @@ -489,6 +489,7 @@ func (ct *Content) PagePDF(path string) error { fname := ct.currentPage.Name + ".pdf" if path != "" { + os.MkdirAll(path, 0777) fname = filepath.Join(path, fname) } f, err := os.Create(fname) diff --git a/content/examples/basic/content/button.md b/content/examples/basic/content/button.md index c8bed34f37..90c21f54d0 100644 --- a/content/examples/basic/content/button.md +++ b/content/examples/basic/content/button.md @@ -99,3 +99,24 @@ Action and menu buttons are the most minimal buttons, and they are typically onl ```Go core.NewButton(b).SetType(core.ButtonAction).SetText("Action") ``` + +* List item 1 + * Sub list item 1 + * Sub list item 2 +* List item 2 + * Sub list item 1 + * Sub list item 2 +* List item 3 + * Sub list item 1 + * Sub list item 2 + +1. List item 1 + 1. Sub list item 1 + 2. Sub list item 2 +2. List item 2 + 1. Sub list item 1 + 2. Sub list item 2 +3. List item 3 + 1. Sub list item 1 + 2. Sub list item 2 + diff --git a/htmlcore/handler.go b/htmlcore/handler.go index bb819f9f56..fdeebf8536 100644 --- a/htmlcore/handler.go +++ b/htmlcore/handler.go @@ -101,7 +101,7 @@ func handleElement(ctx *Context) { if tag == "ol" { w.SetProperty("listCount", 0) } - w.Styler(func(s *styles.Style) { + w.FinalStyler(func(s *styles.Style) { s.Grow.Set(1, 0) s.Padding.Left.Ch(core.ConstantSpacing(float32(4 * ld))) }) diff --git a/text/paginate/extract.go b/text/paginate/extract.go index 88ca3d5481..a4381aa9d7 100644 --- a/text/paginate/extract.go +++ b/text/paginate/extract.go @@ -68,7 +68,7 @@ func (p *pager) extract() []*item { continue } gap := cpw.Styles.Gap.Dots().Floor() - // todo: left margin + left := cpw.Styles.Padding.Left.Dots if cp.i == 0 { gap.Y = 0 } @@ -81,7 +81,7 @@ func (p *pager) extract() []*item { } } } - its = append(its, &item{w: cw.This.(core.Widget), gap: gap}) + its = append(its, &item{w: cw.This.(core.Widget), gap: gap, left: left}) next() if atEnd { break diff --git a/text/paginate/layout.go b/text/paginate/layout.go index 4a288851fe..98f5f070b1 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -9,6 +9,7 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/math32" + "cogentcore.org/core/styles" "cogentcore.org/core/tree" ) @@ -64,21 +65,47 @@ func (p *pager) pagify(its []*item) [][]*item { return pgs } -// layout reorders items within the pages and generates final output. -func (p *pager) layout(its []*item) { - pgs := p.pagify(its) +func (p *pager) outputPages(pgs [][]*item) { for _, pg := range pgs { - // todo: rearrange elements to put text at bottom and non-text at top - - gap := math32.Vector2{} + lastGap := math32.Vector2{} + lastLeft := float32(0) if len(pg) > 0 { - gap = pg[0].gap // todo: better + lastGap = pg[0].gap } - // now transfer over to frame - page, body := p.newPage(gap) + page, body := p.newPage(lastGap) + cpar := body for _, it := range pg { - tree.MoveToParent(it.w, body) + gap := it.gap + left := it.left + if gap != lastGap || left != lastLeft { + cpar = p.newOutFrame(body, gap, left) + lastGap = gap + lastLeft = left + } + tree.MoveToParent(it.w, cpar) } p.outs = append(p.outs, page) } } + +func (p *pager) newOutFrame(par *core.Frame, gap math32.Vector2, left float32) *core.Frame { + fr := core.NewFrame(par) + fr.Styler(func(s *styles.Style) { + s.Direction = styles.Column + s.ZeroSpace() + s.Min.X.Dot(p.opts.BodyDots.X) + s.Max.X.Dot(p.opts.BodyDots.X) + s.Gap.X.Dot(gap.X) + s.Gap.Y.Dot(gap.Y) + s.Padding.Left.Dot(core.ConstantSpacing(left)) + }) + return fr +} + +// layout reorders items within the pages and generates final output. +func (p *pager) layout(its []*item) { + pgs := p.pagify(its) + // note: could rearrange elements to put text at bottom and non-text at top? + // but this is probably not necessary? + p.outputPages(pgs) +} diff --git a/text/paginate/page.go b/text/paginate/page.go index afafa6dce2..650047b983 100644 --- a/text/paginate/page.go +++ b/text/paginate/page.go @@ -12,14 +12,15 @@ import ( "cogentcore.org/core/styles" ) +func styMinMax(s *styles.Style, x, y float32) { + s.ZeroSpace() + s.Min.X.Dot(x) + s.Min.Y.Dot(y) + s.Max.X.Dot(x) + s.Max.Y.Dot(y) +} + func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { - styMinMax := func(s *styles.Style, x, y float32) { - s.ZeroSpace() - s.Min.X.Dot(x) - s.Min.Y.Dot(y) - s.Max.X.Dot(x) - s.Max.Y.Dot(y) - } curPage := len(p.outs) + 1 pn := fmt.Sprintf("page-%d", curPage) From 229ec7f08df9f4f02dde6bc41ada40257cd00d22 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 7 Oct 2025 23:24:36 +0200 Subject: [PATCH 34/99] pdf: MouseUp always clears Active state regardless -- can be set for non-Activatable items --- content/handlers.go | 4 ---- core/widgetevents.go | 5 ++--- styles/style_props.go | 4 ++++ styles/text.go | 15 +++++++++++++++ text/text/props.go | 2 ++ text/text/style.go | 6 ++++++ 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/content/handlers.go b/content/handlers.go index 0f94d54ad0..8e70584524 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -33,10 +33,6 @@ import ( // code blocks in text editors. var BindTextEditor func(ed *textcore.Editor, parent *core.Frame, language string) -// * add after-first-line indent (what is official name?) to text style and implement it -- need for li etc -// * paginate uses left margins -// * not able to click off shading of figures etc when clicking link - // handles the id attribute in htmlcore: needed for equation case func (ct *Content) htmlIDAttributeHandler(ctx *htmlcore.Context, w io.Writer, node ast.Node, entering bool, tag, value string) bool { if ct.currentPage == nil { diff --git a/core/widgetevents.go b/core/widgetevents.go index 45d52ce995..78c92c266b 100644 --- a/core/widgetevents.go +++ b/core/widgetevents.go @@ -335,9 +335,8 @@ func (wb *WidgetBase) handleWidgetStateFromMouse() { } }) wb.On(events.MouseUp, func(e events.Event) { - if wb.AbilityIs(abilities.Activatable) { - wb.SetState(false, states.Active) - } + // note: not contingent on Activatable: always clear on up + wb.SetState(false, states.Active) }) wb.On(events.LongPressStart, func(e events.Event) { if wb.AbilityIs(abilities.LongPressable) { diff --git a/styles/style_props.go b/styles/style_props.go index d288ae6da1..fdf0ff65ad 100644 --- a/styles/style_props.go +++ b/styles/style_props.go @@ -516,6 +516,10 @@ var styleFuncs = map[string]styleprops.Func{ func(obj *Text) enums.EnumSetter { return &obj.WhiteSpace }), "direction": styleprops.Enum(rich.LTR, func(obj *Text) enums.EnumSetter { return &obj.Direction }), + "text-indent": styleprops.Units(units.Value{}, + func(obj *Text) *units.Value { return &obj.Indent }), + "text-hanging": styleprops.Units(units.Value{}, + func(obj *Text) *units.Value { return &obj.Hanging }), "tab-size": styleprops.Int(int(4), func(obj *Text) *int { return &obj.TabSize }), "select-color": func(obj any, key string, val any, parent any, cc colors.Context) { diff --git a/styles/text.go b/styles/text.go index 96331e462d..e5ac793188 100644 --- a/styles/text.go +++ b/styles/text.go @@ -45,6 +45,13 @@ type Text struct { //types:add // unicode text is typically written in a different direction. Direction rich.Directions + // Indent specifies how much to indent the first line in a paragraph (inherited). + Indent units.Value + + // Hanging specifies how much to indent all but the first line + // in a paragraph (inherited). + Hanging units.Value + // TabSize specifies the tab size, in number of characters (inherited). TabSize int @@ -67,6 +74,8 @@ func (ts *Text) Defaults() { // ToDots runs ToDots on unit values, to compile down to raw pixels func (ts *Text) ToDots(uc *units.Context) { + ts.Indent.ToDots(uc) + ts.Hanging.ToDots(uc) } // InheritFields from parent @@ -76,6 +85,8 @@ func (ts *Text) InheritFields(parent *Text) { ts.LineHeight = parent.LineHeight // ts.WhiteSpace = par.WhiteSpace // note: we can't inherit this b/c label base default then gets overwritten ts.Direction = parent.Direction + ts.Indent = parent.Indent + ts.Hanging = parent.Hanging ts.TabSize = parent.TabSize ts.SelectColor = parent.SelectColor ts.HighlightColor = parent.HighlightColor @@ -88,6 +99,8 @@ func (ts *Text) SetText(tsty *text.Style) { tsty.LineHeight = ts.LineHeight tsty.WhiteSpace = ts.WhiteSpace tsty.Direction = ts.Direction + tsty.Indent = ts.Indent + tsty.Hanging = ts.Hanging tsty.TabSize = ts.TabSize tsty.SelectColor = ts.SelectColor tsty.HighlightColor = ts.HighlightColor @@ -100,6 +113,8 @@ func (ts *Text) SetFromText(tsty *text.Style) { ts.LineHeight = tsty.LineHeight ts.WhiteSpace = tsty.WhiteSpace ts.Direction = tsty.Direction + ts.Indent = tsty.Indent + ts.Hanging = tsty.Hanging ts.TabSize = tsty.TabSize ts.SelectColor = tsty.SelectColor ts.HighlightColor = tsty.HighlightColor diff --git a/text/text/props.go b/text/text/props.go index cfe3672226..8eca9cb161 100644 --- a/text/text/props.go +++ b/text/text/props.go @@ -61,6 +61,8 @@ var styleFuncs = map[string]styleprops.Func{ func(obj *Style) enums.EnumSetter { return &obj.Direction }), "text-indent": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.Indent }), + "text-hanging": styleprops.Units(units.Value{}, + func(obj *Style) *units.Value { return &obj.Hanging }), "tab-size": styleprops.Int(int(4), func(obj *Style) *int { return &obj.TabSize }), "select-color": func(obj any, key string, val any, parent any, cc colors.Context) { diff --git a/text/text/style.go b/text/text/style.go index b72845749b..b1087346c0 100644 --- a/text/text/style.go +++ b/text/text/style.go @@ -69,6 +69,10 @@ type Style struct { //types:add // Indent specifies how much to indent the first line in a paragraph (inherited). Indent units.Value + // Hanging specifies how much to indent all but the first line + // in a paragraph (inherited). + Hanging units.Value + // TabSize specifies the tab size, in number of characters (inherited). TabSize int @@ -110,6 +114,7 @@ func (ts *Style) ToDots(uc *units.Context) { ts.FontSize.ToDots(uc) ts.FontSize.Dots = math32.Round(ts.FontSize.Dots) ts.Indent.ToDots(uc) + ts.Hanging.ToDots(uc) } // InheritFields from parent @@ -121,6 +126,7 @@ func (ts *Style) InheritFields(parent *Style) { // ts.WhiteSpace = par.WhiteSpace // todo: we can't inherit this b/c label base default then gets overwritten ts.Direction = parent.Direction ts.Indent = parent.Indent + ts.Hanging = parent.Hanging ts.TabSize = parent.TabSize ts.SelectColor = parent.SelectColor ts.HighlightColor = parent.HighlightColor From d14f36297c2a1a1804dc90a9302ce9dc4f075561 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 8 Oct 2025 01:18:25 +0200 Subject: [PATCH 35/99] pdf: fix the completely bizarre first-text-line-offset issue with PDFs: needs an extra 3 pixels. The ascent thing must be wrong somehow, somewhere.. will fix properly later. --- core/text.go | 7 +++++++ paint/pdf/text.go | 4 ++-- text/paginate/layout.go | 4 +++- text/paginate/pdf.go | 15 ++++++++++++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/core/text.go b/core/text.go index 8de8b424c0..ff51b3ad30 100644 --- a/core/text.go +++ b/core/text.go @@ -119,6 +119,13 @@ const ( func (tx *Text) WidgetValue() any { return &tx.Text } +// PaintText returns the shaped representation of the text, +// which is needed for some specific special-case rendering +// situations. +func (tx *Text) PaintText() *shaped.Lines { + return tx.paintText +} + func (tx *Text) Init() { tx.WidgetBase.Init() tx.AddContextMenu(tx.contextMenu) diff --git a/paint/pdf/text.go b/paint/pdf/text.go index c2c9ebb85e..c8491674f0 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -31,14 +31,14 @@ func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, ln runes := lns.Source.Join() for li := range lns.Lines { ln := &lns.Lines[li] - r.textLine(style, m, ln, lns, runes, clr, off) + r.textLine(style, m, li, ln, lns, runes, clr, off) } r.links(lns, m, pos) r.w.PopStack() } // TextLine rasterizes the given shaped.Line. -func (r *PDF) textLine(style *styles.Paint, m math32.Matrix2, ln *shaped.Line, lns *shaped.Lines, runes []rune, clr image.Image, off math32.Vector2) { +func (r *PDF) textLine(style *styles.Paint, m math32.Matrix2, li int, ln *shaped.Line, lns *shaped.Lines, runes []rune, clr image.Image, off math32.Vector2) { start := off.Add(ln.Offset) off = start for ri := range ln.Runs { diff --git a/text/paginate/layout.go b/text/paginate/layout.go index 98f5f070b1..381da52344 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -39,9 +39,11 @@ func (p *pager) pagify(its []*item) [][]*item { over := ht+sz > maxY if !over && nobrk { if i < n-1 { - nsz := size(its[i+1]) + nsz := size(its[i+1]) + 100 // extra space to be sure if ht+sz+nsz > maxY { over = true // break now + // } else { + // fmt.Println("not !over, nobrk:", ht, sz, nsz, maxY, ht+sz+nsz, cw, its[i+1]) } } } diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index 7eec91fc19..ee74c58992 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -44,7 +44,20 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { tree.MoveToParent(p, sc) p.SetScene(sc) sc.StyleTree() - sc.LayoutRenderScene() + sc.LayoutScene() + + sc.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { + if tx, ok := cwb.This.(*core.Text); ok { + lns := tx.PaintText() + if len(lns.Lines) > 0 { + ln := &lns.Lines[0] + ln.Offset.Y += 3 // todo: seriously, this fixes an otherwise inexplicable offset + } + } + return true + }) + + sc.RenderWidget() rend := sc.Painter.RenderDone() pdr.RenderPage(rend) From 9a085e7271882b45c6b4dce99c26af78e4c07ab5 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 8 Oct 2025 02:07:32 +0200 Subject: [PATCH 36/99] pdf: render uniform boxes, fix path cases -- just need gradient now; getting weird sizing issue in BG -- can't quite figure it out. --- paint/pdf/pdf.go | 50 ++++++++++++-------------- paint/renderers/pdfrender/pdfrender.go | 7 +--- svg/svg_test.go | 2 +- text/paginate/layout.go | 11 +++--- text/paginate/pdf.go | 4 ++- 5 files changed, 31 insertions(+), 43 deletions(-) diff --git a/paint/pdf/pdf.go b/paint/pdf/pdf.go index 6e49389ccc..640766873c 100644 --- a/paint/pdf/pdf.go +++ b/paint/pdf/pdf.go @@ -176,7 +176,8 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { closed = true } - if style.HasStroke() && strokeUnsupported { // todo + if style.HasStroke() && strokeUnsupported { + // todo: handle with optional inclusion of stroke function as _ import /* // style.HasStroke() && strokeUnsupported if style.HasFill() { r.w.SetFill(style.Fill) @@ -199,7 +200,7 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { r.w.Write([]byte(path.Transform(m).ToPDF())) r.w.Write([]byte(" f")) */ - return + // return } if style.HasFill() && !style.HasStroke() { r.w.SetFill(&style.Fill) @@ -238,34 +239,27 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { if style.Fill.Rule == ppath.EvenOdd { r.w.Write([]byte("*")) } - } - /* - else { - r.w.SetFill(style.Fill) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - r.w.Write([]byte(" f")) - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } + } else { + r.w.SetFill(&style.Fill) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } - r.w.SetStroke(style.Stroke) - r.w.SetLineWidth(style.StrokeWidth) - r.w.SetLineCap(style.StrokeCapper) - r.w.SetLineJoin(style.StrokeJoiner) - r.w.SetDashes(style.DashOffset, style.Dashes) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - if closed { - r.w.Write([]byte(" s")) - } else { - r.w.Write([]byte(" S")) - } - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } + r.w.SetStroke(&style.Stroke) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" s")) + } else { + r.w.Write([]byte(" S")) } - */ + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } } } diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index b4d0d84d86..5a191f80ae 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -160,12 +160,7 @@ func (rs *Renderer) RenderImage(pr *pimage.Params) { // Fast path for [image.Uniform] if u, ok := usrc.(*image.Uniform); nilSrc || ok && umask == nil { - _ = u - // todo: draw a box - // r := fpdf.NewRect(cg) - // r.Pos = math32.FromPoint(pr.Rect.Min) - // r.Size = math32.FromPoint(pr.Rect.Size()) - // r.SetProperty("fill", colors.AsHex(u.C)) + rs.PDF.FillBox(math32.Identity2(), math32.B2FromRect(pr.Rect), u) return } diff --git a/svg/svg_test.go b/svg/svg_test.go index 8663c1f574..9a7475c962 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -155,7 +155,7 @@ func TestEmoji(t *testing.T) { } func TestFontEmoji(t *testing.T) { - // t.Skip("special-case testing -- requires noto-emoji file") + t.Skip("special-case testing -- requires noto-emoji file") // dir := filepath.Join("testdata", "noto-emoji") os.MkdirAll("testdata/font-emoji-src", 0777) fname := "/Library/Fonts/NotoColorEmoji-Regular.ttf" diff --git a/text/paginate/layout.go b/text/paginate/layout.go index 381da52344..cb4c2f0984 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -5,8 +5,6 @@ package paginate import ( - "fmt" - "cogentcore.org/core/core" "cogentcore.org/core/math32" "cogentcore.org/core/styles" @@ -17,11 +15,10 @@ import ( func (p *pager) pagify(its []*item) [][]*item { widg := core.AsWidget size := func(it *item) float32 { - ih := widg(it.w).Geom.Size.Actual.Total.Y - if ih == 0 { - fmt.Println("zero height", ih, it.w) - } - return ih + it.gap.Y + wb := widg(it.w) + ih := wb.Geom.Size.Actual.Total.Y // todo: something wrong with size! + sz := ih + it.gap.Y + return sz } maxY := p.opts.BodyDots.Y diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index ee74c58992..2ea1f2e2ca 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -31,6 +31,8 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { p.preRender() p.paginate() + // osc := ins[0].AsWidget().Scene + sc := core.NewScene() sz := math32.Geom2DInt{} sz.Size = opts.SizeDots.ToPointCeil() @@ -57,7 +59,7 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { return true }) - sc.RenderWidget() + p.RenderWidget() rend := sc.Painter.RenderDone() pdr.RenderPage(rend) From a497b88f2f84602001fc8f7ed19ebfadee3341ed Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 8 Oct 2025 09:12:04 +0200 Subject: [PATCH 37/99] pdf: pdf sizing issues all fixed -- need to pre-render in the same generic config as final rendering will use. --- content/content.go | 3 +++ core/events.go | 4 ++++ text/paginate/layout.go | 46 +++++++++++++++++++++++++++++++-------- text/paginate/page.go | 10 ++++----- text/paginate/paginate.go | 8 +++---- text/paginate/pdf.go | 9 ++++---- 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/content/content.go b/content/content.go index 88404eabc0..01966d1956 100644 --- a/content/content.go +++ b/content/content.go @@ -500,6 +500,9 @@ func (ct *Content) PagePDF(path string) error { paginate.PDF(f, opts.PDF, ct.rightFrame) err = f.Close() ct.reloadPage() + core.MessageSnackbar(ct, "PDF saved to: "+fname) + af := errors.Log1(filepath.Abs(fname)) + core.TheApp.OpenURL("file://" + af) return err } diff --git a/core/events.go b/core/events.go index a545ed94fb..399f52261c 100644 --- a/core/events.go +++ b/core/events.go @@ -1212,6 +1212,10 @@ func (em *Events) managerKeyChordEvents(e events.Event) { pd := pdr.Render(rend).Source() fnm = filepath.Join(TheApp.AppDataDir(), "screenshot-"+sc.Name+"-"+dstr+".pdf") errors.Log(os.WriteFile(fnm, pd, 0666)) + + sc.SetScene(sc) + sc.Update() + MessageSnackbar(sc, "Saved SVG, PDF screenshots to: "+strings.ReplaceAll(fnm, " ", `\ `)+sz) e.SetHandled() case keymap.ZoomIn: diff --git a/text/paginate/layout.go b/text/paginate/layout.go index cb4c2f0984..6df9d26aa5 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -17,8 +17,7 @@ func (p *pager) pagify(its []*item) [][]*item { size := func(it *item) float32 { wb := widg(it.w) ih := wb.Geom.Size.Actual.Total.Y // todo: something wrong with size! - sz := ih + it.gap.Y - return sz + return ih + it.gap.Y } maxY := p.opts.BodyDots.Y @@ -36,7 +35,7 @@ func (p *pager) pagify(its []*item) [][]*item { over := ht+sz > maxY if !over && nobrk { if i < n-1 { - nsz := size(its[i+1]) + 100 // extra space to be sure + nsz := size(its[i+1]) // extra space to be sure if ht+sz+nsz > maxY { over = true // break now // } else { @@ -64,14 +63,15 @@ func (p *pager) pagify(its []*item) [][]*item { return pgs } -func (p *pager) outputPages(pgs [][]*item) { - for _, pg := range pgs { +func (p *pager) outputPages(pgs [][]*item, newPage func(gap math32.Vector2, pageNo int) (page, body *core.Frame)) []*core.Frame { + var outs []*core.Frame + for pn, pg := range pgs { lastGap := math32.Vector2{} lastLeft := float32(0) if len(pg) > 0 { lastGap = pg[0].gap } - page, body := p.newPage(lastGap) + page, body := newPage(lastGap, pn+1) cpar := body for _, it := range pg { gap := it.gap @@ -83,12 +83,18 @@ func (p *pager) outputPages(pgs [][]*item) { } tree.MoveToParent(it.w, cpar) } - p.outs = append(p.outs, page) + outs = append(outs, page) } + return outs } func (p *pager) newOutFrame(par *core.Frame, gap math32.Vector2, left float32) *core.Frame { - fr := core.NewFrame(par) + var fr *core.Frame + if par != nil { + fr = core.NewFrame(par) + } else { + fr = core.NewFrame() + } fr.Styler(func(s *styles.Style) { s.Direction = styles.Column s.ZeroSpace() @@ -101,10 +107,32 @@ func (p *pager) newOutFrame(par *core.Frame, gap math32.Vector2, left float32) * return fr } +// pre-render everything in the offscreen scene that will be used for final +// to get accurate element sizes. +func (p *pager) preRender(its []*item) { + pg := [][]*item{its} + op := p.outputPages(pg, func(gap math32.Vector2, pageNo int) (page, body *core.Frame) { + fr := p.newOutFrame(nil, gap, 0) + page, body = fr, fr + return + }) + sc := core.NewScene() + sz := math32.Geom2DInt{} + sz.Size = p.opts.SizeDots.ToPointCeil() + sc.Resize(sz) + sc.MakeTextShaper() + + tree.MoveToParent(op[0], sc) + op[0].SetScene(sc) + sc.StyleTree() + sc.LayoutScene() +} + // layout reorders items within the pages and generates final output. func (p *pager) layout(its []*item) { + p.preRender(its) pgs := p.pagify(its) // note: could rearrange elements to put text at bottom and non-text at top? // but this is probably not necessary? - p.outputPages(pgs) + p.outs = p.outputPages(pgs, p.newPage) } diff --git a/text/paginate/page.go b/text/paginate/page.go index 650047b983..7c092768a7 100644 --- a/text/paginate/page.go +++ b/text/paginate/page.go @@ -20,10 +20,8 @@ func styMinMax(s *styles.Style, x, y float32) { s.Max.Y.Dot(y) } -func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { - - curPage := len(p.outs) + 1 - pn := fmt.Sprintf("page-%d", curPage) +func (p *pager) newPage(gap math32.Vector2, pageNo int) (page, body *core.Frame) { + pn := fmt.Sprintf("page-%d", pageNo) page = core.NewFrame() page.SetName(pn) @@ -51,7 +49,7 @@ func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { styMinMax(s, p.opts.BodyDots.X, p.opts.MargDots.Top) }) if p.opts.Header != nil { - p.opts.Header(hdr, p.opts, curPage) + p.opts.Header(hdr, p.opts, pageNo) } body = core.NewFrame(bfr) @@ -70,7 +68,7 @@ func (p *pager) newPage(gap math32.Vector2) (page, body *core.Frame) { styMinMax(s, p.opts.BodyDots.X, p.opts.MargDots.Bottom) }) if p.opts.Footer != nil { - p.opts.Footer(ftr, p.opts, curPage) + p.opts.Footer(ftr, p.opts, pageNo) } return diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index 2dc60a59e3..b50274479c 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -6,7 +6,6 @@ package paginate import ( "cogentcore.org/core/core" - "cogentcore.org/core/math32" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" @@ -45,9 +44,9 @@ func (p *pager) optsUpdate() { p.opts.ToDots(&p.ctx) } -// preRender re-renders inputs with styles enforced to fit page size, -// and setting the font family and size for text elements. -func (p *pager) preRender() { +// assemble collects everything to be rendered into one big list, +// and sets the font family and size for text elements. +func (p *pager) assemble() { sc := core.AsWidget(p.ins[0]).Scene // sc.AsyncLock() if p.opts.Title != nil { @@ -60,7 +59,6 @@ func (p *pager) preRender() { p.opts.Title(tf, p.opts) p.ins = append([]core.Widget{tf.This.(core.Widget)}, p.ins...) tf.StyleTree() - tf.LayoutFrame(math32.FromPoint(tf.Scene.SceneGeom.Size)) } for _, in := range p.ins { iw := core.AsWidget(in) diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index 2ea1f2e2ca..cd1656fde3 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -12,6 +12,7 @@ import ( "cogentcore.org/core/paint" "cogentcore.org/core/paint/pdf" "cogentcore.org/core/paint/renderers/pdfrender" + "cogentcore.org/core/styles/units" "cogentcore.org/core/tree" ) @@ -27,12 +28,12 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { cset := pdf.UseStandardFonts() p := pager{opts: &opts, ins: ins} - p.optsUpdate() - p.preRender() + p.opts.Update() + p.ctx = *units.NewContext() // generic, invariant of actual context + p.opts.ToDots(&p.ctx) + p.assemble() p.paginate() - // osc := ins[0].AsWidget().Scene - sc := core.NewScene() sz := math32.Geom2DInt{} sz.Size = opts.SizeDots.ToPointCeil() From 20df3c5c0d9ecd228fcda09f3218fee7a594376f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 8 Oct 2025 10:57:36 +0200 Subject: [PATCH 38/99] pdf: js/web version downloads the PDF file -- fixed crash there and have to rebuild core tool to avoid error with Authors change to string instead of []string. --- content/buttons.go | 2 +- content/typegen.go | 2 +- content/url_js.go | 19 ++++++++++++++----- paint/pdf/page.go | 6 +++++- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/content/buttons.go b/content/buttons.go index d751d6649f..50cf3db8da 100644 --- a/content/buttons.go +++ b/content/buttons.go @@ -75,7 +75,7 @@ func (ct *Content) MakeToolbar(p *tree.Plan) { }) }) tree.Add(p, func(w *core.Button) { - w.SetText("PDF").SetIcon(icons.PictureAsPdf) + w.SetText("PDF").SetIcon(icons.PictureAsPdf).SetTooltip("PDF generates and opens / downloads the current page as a printable PDF file") w.OnClick(func(e events.Event) { ct.PagePDF("pdfs") }) diff --git a/content/typegen.go b/content/typegen.go index 62b815c568..a9df91b5c8 100644 --- a/content/typegen.go +++ b/content/typegen.go @@ -8,7 +8,7 @@ import ( "cogentcore.org/core/types" ) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/content.Content", IDName: "content", Doc: "Content manages and displays the content of a set of pages.", Embeds: []types.Field{{Name: "Splits"}}, Fields: []types.Field{{Name: "Source", Doc: "Source is the source filesystem for the content.\nIt should be set using [Content.SetSource] or [Content.SetContent]."}, {Name: "Context", Doc: "Context is the [htmlcore.Context] used to render the content,\nwhich can be modified for things such as adding wikilink handlers."}, {Name: "References", Doc: "References is a list of references used for generating citation text\nfor literature reference wikilinks in the format [[@CiteKey]]."}, {Name: "pages", Doc: "pages are the pages that constitute the content."}, {Name: "pagesByName", Doc: "pagesByName has the [bcontent.Page] for each [bcontent.Page.Name]\ntransformed into lowercase. See [Content.pageByName] for a helper\nfunction that automatically transforms into lowercase."}, {Name: "pagesByURL", Doc: "pagesByURL has the [bcontent.Page] for each [bcontent.Page.URL]."}, {Name: "pagesByCategory", Doc: "pagesByCategory has the [bcontent.Page]s for each of all [bcontent.Page.Categories]."}, {Name: "categories", Doc: "categories has all unique [bcontent.Page.Categories], sorted such that the categories\nwith the most pages are listed first."}, {Name: "history", Doc: "history is the history of pages that have been visited.\nThe oldest page is first."}, {Name: "historyIndex", Doc: "historyIndex is the current position in [Content.history]."}, {Name: "currentPage", Doc: "currentPage is the currently open page."}, {Name: "renderedPage", Doc: "renderedPage is the most recently rendered page."}, {Name: "leftFrame", Doc: "leftFrame is the frame on the left side of the widget,\nused for displaying the table of contents and the categories."}, {Name: "rightFrame", Doc: "rightFrame is the frame on the right side of the widget,\nused for displaying the page content."}, {Name: "tocNodes", Doc: "tocNodes are all of the tree nodes in the table of contents\nby kebab-case heading name."}, {Name: "currentHeading", Doc: "currentHeading is the currently selected heading in the table of contents,\nif any (in kebab-case)."}, {Name: "prevPage", Doc: "The previous and next page, if applicable. They must be stored on this struct\nto avoid stale local closure variables."}, {Name: "nextPage", Doc: "The previous and next page, if applicable. They must be stored on this struct\nto avoid stale local closure variables."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/content.Content", IDName: "content", Doc: "Content manages and displays the content of a set of pages.", Embeds: []types.Field{{Name: "Splits"}}, Fields: []types.Field{{Name: "Source", Doc: "Source is the source filesystem for the content.\nIt should be set using [Content.SetSource] or [Content.SetContent]."}, {Name: "Context", Doc: "Context is the [htmlcore.Context] used to render the content,\nwhich can be modified for things such as adding wikilink handlers."}, {Name: "References", Doc: "References is a list of references used for generating citation text\nfor literature reference wikilinks in the format [[@CiteKey]]."}, {Name: "pages", Doc: "pages are the pages that constitute the content."}, {Name: "pagesByName", Doc: "pagesByName has the [bcontent.Page] for each [bcontent.Page.Name]\ntransformed into lowercase. See [Content.pageByName] for a helper\nfunction that automatically transforms into lowercase."}, {Name: "pagesByURL", Doc: "pagesByURL has the [bcontent.Page] for each [bcontent.Page.URL]."}, {Name: "pagesByCategory", Doc: "pagesByCategory has the [bcontent.Page]s for each of all [bcontent.Page.Categories]."}, {Name: "categories", Doc: "categories has all unique [bcontent.Page.Categories], sorted such that the categories\nwith the most pages are listed first. \"Other\" is always last, and is used for pages that\ndo not have a category, unless they are a category themselves."}, {Name: "history", Doc: "history is the history of pages that have been visited.\nThe oldest page is first."}, {Name: "historyIndex", Doc: "historyIndex is the current position in [Content.history]."}, {Name: "currentPage", Doc: "currentPage is the currently open page."}, {Name: "renderedPage", Doc: "renderedPage is the most recently rendered page."}, {Name: "leftFrame", Doc: "leftFrame is the frame on the left side of the widget,\nused for displaying the table of contents and the categories."}, {Name: "rightFrame", Doc: "rightFrame is the frame on the right side of the widget,\nused for displaying the page content."}, {Name: "tocNodes", Doc: "tocNodes are all of the tree nodes in the table of contents\nby kebab-case heading name."}, {Name: "currentHeading", Doc: "currentHeading is the currently selected heading in the table of contents,\nif any (in kebab-case)."}, {Name: "inPDFRender", Doc: "inPDFRender indicates that it is rendering a PDF now, turning off\nelements that are not appropriate for that."}, {Name: "prevPage", Doc: "The previous and next page, if applicable. They must be stored on this struct\nto avoid stale local closure variables."}, {Name: "nextPage", Doc: "The previous and next page, if applicable. They must be stored on this struct\nto avoid stale local closure variables."}}}) // NewContent returns a new [Content] with the given optional parent: // Content manages and displays the content of a set of pages. diff --git a/content/url_js.go b/content/url_js.go index fae4fa1843..c8b194b5b0 100644 --- a/content/url_js.go +++ b/content/url_js.go @@ -14,11 +14,17 @@ import ( "cogentcore.org/core/base/errors" ) -// firstContent is the first [Content] used for [Content.getWebURL] or [Content.saveWebURL], -// which is used to prevent nested [Content] widgets from incorrectly affecting the URL. -var firstContent *Content +var ( + // firstContent is the first [Content] used for [Content.getWebURL] or [Content.saveWebURL], + // which is used to prevent nested [Content] widgets from incorrectly affecting the URL. + firstContent *Content -var documentData = js.Global().Get("document").Get("documentElement").Get("dataset") + documentData = js.Global().Get("document").Get("documentElement").Get("dataset") + + // OfflineURL is the non-web base url, which can be set to allow + // docs to refer to this in frontmatter. + OfflineURL = "" +) func (ct *Content) getPrintURL() string { return ct.getWebURL() } @@ -35,7 +41,10 @@ func (ct *Content) getWebURL() string { if errors.Log(err) != nil { return "" } - return strings.Trim(strings.TrimPrefix(full.String(), base.String()), "/") + ur := strings.Trim(strings.TrimPrefix(full.String(), base.String()), "/") + // fmt.Println("url is:", ur) + OfflineURL = ur + return ur } // saveWebURL saves the current page URL to the user's address bar and history. diff --git a/paint/pdf/page.go b/paint/pdf/page.go index d4ec1b3e50..2faf53cc32 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -11,6 +11,7 @@ import ( "bytes" "fmt" "image" + "image/color" "log/slog" "cogentcore.org/core/base/errors" @@ -117,7 +118,10 @@ func (w *pdfPage) SetFillColor(fill *styles.Fill) { // TODO: should we unset cs? // fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(fill.Gradient)) case *image.Uniform: - clr := colors.ApplyOpacity(colors.AsRGBA(x), fill.Opacity) + var clr color.RGBA + if x != nil { + clr = colors.ApplyOpacity(colors.AsRGBA(x), fill.Opacity) + } a := float32(clr.A) / 255.0 if clr.R == clr.G && clr.R == clr.B { fmt.Fprintf(w, " %v g", dec(float32(clr.R)/255.0/a)) From 6441853aa61309b8187c55ecf4f88ea1da90248f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 8 Oct 2025 11:59:49 +0200 Subject: [PATCH 39/99] pdf: pdf generates references for current page; updated csl to use fs.FS for compatibility. --- content/content.go | 31 ++++++++++++++++++++++++++- text/csl/md.go | 42 ++++++++++-------------------------- text/csl/mdcite/mdcite.go | 24 ++++++++++++++++++++- text/paginate/paginate.go | 44 -------------------------------------- text/paginate/pdf.go | 45 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 77 deletions(-) diff --git a/content/content.go b/content/content.go index 01966d1956..1ab18de796 100644 --- a/content/content.go +++ b/content/content.go @@ -487,6 +487,8 @@ func (ct *Content) PagePDF(path string) error { ct.Update() ct.inPDFRender = false + refs := ct.PageRefs(ct.currentPage) + fname := ct.currentPage.Name + ".pdf" if path != "" { os.MkdirAll(path, 0777) @@ -497,8 +499,13 @@ func (ct *Content) PagePDF(path string) error { return err } opts := Settings.PageSettings(ct, ct.currentPage) - paginate.PDF(f, opts.PDF, ct.rightFrame) + if refs != nil { + paginate.PDF(f, opts.PDF, ct.rightFrame, refs) + } else { + paginate.PDF(f, opts.PDF, ct.rightFrame) + } err = f.Close() + ct.reloadPage() core.MessageSnackbar(ct, "PDF saved to: "+fname) @@ -506,3 +513,25 @@ func (ct *Content) PagePDF(path string) error { core.TheApp.OpenURL("file://" + af) return err } + +// PageRefs returns a core.Frame with the contents of the references cited +// on the given page. if References is nil, or error, result will be nil. +func (ct *Content) PageRefs(page *bcontent.Page) *core.Frame { + if ct.References == nil { + return nil + } + sty := csl.APA // todo: settings + var b bytes.Buffer + _, err := csl.GenerateMarkdown(&b, ct.Source, "## References", ct.References, sty, page.Filename) + if errors.Log(err) != nil { + return nil + } + + fr := core.NewFrame() + err = htmlcore.ReadMD(ct.Context, fr, b.Bytes()) + if errors.Log(err) != nil { + return nil + } + fr.SetScene(ct.Scene) + return fr +} diff --git a/text/csl/md.go b/text/csl/md.go index d60e934c11..ed5d0d2a72 100644 --- a/text/csl/md.go +++ b/text/csl/md.go @@ -7,50 +7,29 @@ package csl import ( "bufio" "io" - "os" - "path/filepath" + "io/fs" "regexp" "strings" "cogentcore.org/core/base/errors" - "cogentcore.org/core/base/fsx" ) // GenerateMarkdown extracts markdown citations in the format [@Ref; @Ref] -// from .md markdown files in given directory, looking up in given source [KeyList], -// and writing the results in given style to given .md file (references.md default). +// from given mds markdown files in given filesystem (use os.DirFS for filesys), +// looking up in given source [KeyList], and writing the results in given style to writer. // Heading is written first: must include the appropriate markdown heading level // (## typically). Returns the [KeyList] of references that were cited. -func GenerateMarkdown(dir, refFile, heading string, kl *KeyList, sty Styles) (*KeyList, error) { +func GenerateMarkdown(w io.Writer, fsys fs.FS, heading string, kl *KeyList, sty Styles, mds ...string) (*KeyList, error) { cited := &KeyList{} - if dir == "" { - dir = "./" - } - mds := fsx.Filenames(dir, ".md") - if len(mds) == 0 { - return cited, errors.New("No .md files found in: " + dir) - } var errs []error - for i := range mds { - mds[i] = filepath.Join(dir, mds[i]) - } - err := ExtractMarkdownCites(mds, kl, cited) - if err != nil { - errs = append(errs, err) - } - if refFile == "" { - refFile = filepath.Join(dir, "references.md") - } - of, err := os.Create(refFile) + err := ExtractMarkdownCites(fsys, mds, kl, cited) if err != nil { errs = append(errs, err) - return cited, errors.Join(errs...) } - defer of.Close() if heading != "" { - of.WriteString(heading + "\n\n") + w.Write([]byte(heading + "\n\n")) } - err = WriteRefsMarkdown(of, cited, sty) + err = WriteRefsMarkdown(w, cited, sty) if err != nil { errs = append(errs, err) } @@ -58,12 +37,13 @@ func GenerateMarkdown(dir, refFile, heading string, kl *KeyList, sty Styles) (*K } // ExtractMarkdownCites extracts markdown citations in the format [@Ref; @Ref] -// from given list of .md files, looking up in given source [KeyList], adding to cited. -func ExtractMarkdownCites(files []string, src, cited *KeyList) error { +// from given list of .md files in given FS, +// looking up in given source [KeyList], adding to cited. +func ExtractMarkdownCites(fsys fs.FS, files []string, src, cited *KeyList) error { exp := regexp.MustCompile(`\[(@\^?([[:alnum:]]+-?)+(;[[:blank:]]+)?)+\]`) var errs []error for _, fn := range files { - f, err := os.Open(fn) + f, err := fsys.Open(fn) if err != nil { errs = append(errs, err) continue diff --git a/text/csl/mdcite/mdcite.go b/text/csl/mdcite/mdcite.go index 1e22b7c540..0fed65513f 100644 --- a/text/csl/mdcite/mdcite.go +++ b/text/csl/mdcite/mdcite.go @@ -5,6 +5,11 @@ package main import ( + "os" + "path/filepath" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/base/fsx" "cogentcore.org/core/cli" "cogentcore.org/core/text/csl" ) @@ -45,7 +50,24 @@ func Generate(c *Config) error { return err } kl := csl.NewKeyList(refs) - cited, err := csl.GenerateMarkdown(c.Dir, c.Output, c.Heading, kl, c.Style) + + if c.Dir == "" { + c.Dir = "./" + } + mds := fsx.Filenames(c.Dir, ".md") + if len(mds) == 0 { + return errors.New("No .md files found in: " + c.Dir) + } + if c.Output == "" { + c.Output = filepath.Join(c.Dir, "references.md") + } + of, err := os.Create(c.Output) + if errors.Log(err) != nil { + return err + } + defer of.Close() + + cited, err := csl.GenerateMarkdown(of, os.DirFS(c.Dir), c.Heading, kl, c.Style, mds...) cf := c.CitedData if cf == "" { cf = "citedrefs.json" diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index b50274479c..28dc11d75c 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -6,9 +6,7 @@ package paginate import ( "cogentcore.org/core/core" - "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" - "cogentcore.org/core/text/rich" _ "cogentcore.org/core/text/tex" ) @@ -44,48 +42,6 @@ func (p *pager) optsUpdate() { p.opts.ToDots(&p.ctx) } -// assemble collects everything to be rendered into one big list, -// and sets the font family and size for text elements. -func (p *pager) assemble() { - sc := core.AsWidget(p.ins[0]).Scene - // sc.AsyncLock() - if p.opts.Title != nil { - tf := core.NewFrame() - tf.Scene = sc - tf.FinalStyler(func(s *styles.Style) { - s.Min.X.Dot(p.opts.BodyDots.X) - s.Min.Y.Dot(p.opts.BodyDots.Y) - }) - p.opts.Title(tf, p.opts) - p.ins = append([]core.Widget{tf.This.(core.Widget)}, p.ins...) - tf.StyleTree() - } - for _, in := range p.ins { - iw := core.AsWidget(in) - - iw.FinalStyler(func(s *styles.Style) { - s.Min.X.Dot(p.opts.BodyDots.X) - s.Min.Y.Dot(p.opts.BodyDots.Y) - }) - iw.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { - if tx, ok := cwb.This.(*core.Text); ok { - if tx.Styles.Font.Family == rich.SansSerif { - if _, ok := cwb.Parent.(*core.Frame); ok { // not inside buttons etc - cwb.Styler(func(s *styles.Style) { - s.Font.Family = p.opts.FontFamily - }) - } - } - } - return true - }) - - iw.Scene.StyleTree() - iw.Scene.LayoutRenderScene() - } - // sc.AsyncUnlock() -} - func (p *pager) paginate() { its := p.extract() p.layout(its) diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index cd1656fde3..812d6ee0cf 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -12,7 +12,9 @@ import ( "cogentcore.org/core/paint" "cogentcore.org/core/paint/pdf" "cogentcore.org/core/paint/renderers/pdfrender" + "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -72,3 +74,46 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { pdr.EndRender() pdf.RestorePreviousFonts(cset) } + +// assemble collects everything to be rendered into one big list, +// and sets the font family and size for text elements. +// only for full format rendering (e.g., PDF) +func (p *pager) assemble() { + sc := core.AsWidget(p.ins[0]).Scene + // sc.AsyncLock() + if p.opts.Title != nil { + tf := core.NewFrame() + tf.Scene = sc + tf.FinalStyler(func(s *styles.Style) { + s.Min.X.Dot(p.opts.BodyDots.X) + s.Min.Y.Dot(p.opts.BodyDots.Y) + }) + p.opts.Title(tf, p.opts) + p.ins = append([]core.Widget{tf.This.(core.Widget)}, p.ins...) + tf.StyleTree() + } + for _, in := range p.ins { + iw := core.AsWidget(in) + + iw.FinalStyler(func(s *styles.Style) { + s.Min.X.Dot(p.opts.BodyDots.X) + s.Min.Y.Dot(p.opts.BodyDots.Y) + }) + iw.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { + if tx, ok := cwb.This.(*core.Text); ok { + if tx.Styles.Font.Family == rich.SansSerif { + if _, ok := cwb.Parent.(*core.Frame); ok { // not inside buttons etc + cwb.Styler(func(s *styles.Style) { + s.Font.Family = p.opts.FontFamily + }) + } + } + } + return true + }) + + iw.Scene.StyleTree() + iw.Scene.LayoutRenderScene() + } + // sc.AsyncUnlock() +} From f8dc3f6d9a79298cfc1158bb0e76b6c1f24f6e31 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 09:58:19 +0200 Subject: [PATCH 40/99] pdf: pdf links fully working finally. wow that was an ordeal. also: made delayed loading of html images optional and off by default -- content does not at all benefit from this, and it interferes with the PDF rendering. --- content/content.go | 2 +- content/handlers.go | 22 +++- content/settings.go | 2 +- core/image.go | 5 +- core/text.go | 6 ++ htmlcore/context.go | 4 + htmlcore/handler.go | 142 +++++++++++++++---------- paint/painter.go | 24 +++-- paint/pdf/links.go | 53 +++++++++ paint/pdf/page.go | 77 +------------- paint/pdf/pdf.go | 8 +- paint/pdf/pdf_test.go | 23 ++++ paint/pdf/text.go | 5 +- paint/pdf/writer.go | 106 ++++++++++++++++-- paint/pimage/pimage.go | 5 + paint/renderers/pdfrender/pdfrender.go | 4 + text/shaped/lines.go | 5 + 17 files changed, 337 insertions(+), 156 deletions(-) create mode 100644 paint/pdf/links.go diff --git a/content/content.go b/content/content.go index 1ab18de796..343fd4b60a 100644 --- a/content/content.go +++ b/content/content.go @@ -484,7 +484,7 @@ func (ct *Content) PagePDF(path string) error { return errors.Log(errors.New("Page empty")) } ct.inPDFRender = true - ct.Update() + ct.reloadPage() ct.inPDFRender = false refs := ct.PageRefs(ct.currentPage) diff --git a/content/handlers.go b/content/handlers.go index 8e70584524..372d65a9a1 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -81,7 +81,9 @@ func (ct *Content) htmlPreHandler(ctx *htmlcore.Context) bool { if id != "" { fr.SetProperty("id", id) fr.SetName(id) - tree.MoveToParent(parent.Children[parent.NumChildren()-2], fr) // get title text + ttx := parent.Children[parent.NumChildren()-2].(core.Widget) + ttx.AsWidget().SetProperty("id", id) // link target + tree.MoveToParent(ttx, fr) // get title text } if collapsed != "" { cl := core.NewCollapser(fr) @@ -208,10 +210,12 @@ func (ct *Content) moveToBlockFrame(w core.Widget, id, txt string, top bool) { var tx *core.Text if top { tx = core.NewText(fr).SetText(txt) + tx.SetProperty("id", id) // good link destination } tree.MoveToParent(w, fr) if !top { tx = core.NewText(fr).SetText(txt) + wb.SetProperty("id", id) // link here } tx.Styler(func(s *styles.Style) { s.Max.X.In(8) @@ -255,7 +259,11 @@ func (ct *Content) citeWikilink(text string) (url string, label string) { cs = csl.Narrative ref = ref[1:] } - url = "ref://" + ref + if ct.inPDFRender { + url = "#" + ref + } else { + url = "ref://" + ref + } if ct.References == nil { return url, ref } @@ -297,6 +305,16 @@ func (ct *Content) mainWikilink(text string) (url string, label string) { label = name } } + if ct.inPDFRender { + if heading != "" { + if pg == ct.currentPage { + return "#" + heading, label + } + return ct.getPrintURL() + "/" + pg.URL + "#" + heading, label + } + return ct.getPrintURL() + "/" + pg.URL, label + } + if heading != "" { return pg.URL + "#" + heading, label } diff --git a/content/settings.go b/content/settings.go index 2a20ba9151..b993e76497 100644 --- a/content/settings.go +++ b/content/settings.go @@ -43,7 +43,7 @@ func (s *SettingsData) Defaults() { ps := &SettingsData{} *ps = Settings pt := curPage.Title - if ps.SiteTitle != "" { + if ps.SiteTitle != "" && pt == curPage.Name { pt = ps.SiteTitle + ": " + pt } ps.PDF.Header = paginate.NoFirst(paginate.HeaderLeftPageNumber(pt)) diff --git a/core/image.go b/core/image.go index 2f69c65549..df20a3b6f0 100644 --- a/core/image.go +++ b/core/image.go @@ -109,7 +109,10 @@ func (im *Image) Render() { rimg = im.Styles.ResizeImage(im.Image, im.Geom.Size.Actual.Content) im.prevRenderImage = rimg } - im.Scene.Painter.DrawImage(rimg, r, sp, draw.Over) + pim := im.Scene.Painter.DrawImage(rimg, r, sp, draw.Over) + if id, ok := im.Properties["id"]; ok { + pim.Anchor = id.(string) + } } func (im *Image) MakeToolbar(p *tree.Plan) { diff --git a/core/text.go b/core/text.go index ff51b3ad30..9ee392df6f 100644 --- a/core/text.go +++ b/core/text.go @@ -428,6 +428,9 @@ func (tx *Text) configTextSize(sz math32.Vector2) { sty, tsty := tx.Styles.NewRichText() tsty.Align, tsty.AlignV = text.Start, text.Start tx.paintText = tx.Scene.TextShaper().WrapLines(tx.richText, sty, tsty, sz) + if id, ok := tx.Properties["id"]; ok { + tx.paintText.Anchor = id.(string) + } } // configTextAlloc is used for determining how much space the text @@ -452,6 +455,9 @@ func (tx *Text) configTextAlloc(sz math32.Vector2) math32.Vector2 { rsz = tx.paintText.Bounds.Size().Ceil() } tx.paintText = tsh.WrapLines(tx.richText, sty, tsty, rsz) + if id, ok := tx.Properties["id"]; ok { + tx.paintText.Anchor = id.(string) + } return tx.paintText.Bounds.Size().Ceil() } diff --git a/htmlcore/context.go b/htmlcore/context.go index f1ed184cbf..f82a7587e1 100644 --- a/htmlcore/context.go +++ b/htmlcore/context.go @@ -93,6 +93,10 @@ type Context struct { // type of widget, for example. WidgetHandlers []func(w core.Widget) + // DelayedImageLoad causes images to be loaded at a delay. + // if not set, then they are loaded immediately. + DelayedImageLoad bool + // firstRow indicates the start of a table, where number of columns is counted. firstRow bool } diff --git a/htmlcore/handler.go b/htmlcore/handler.go index fdeebf8536..d12210a93d 100644 --- a/htmlcore/handler.go +++ b/htmlcore/handler.go @@ -53,12 +53,6 @@ func handleElement(ctx *Context) { return } - pid := "" - pstyle := "" - if ctx.BlockParent != nil { // these attributes get put on a block parent element - pstyle = GetAttr(ctx.Node.Parent, "style") - pid = GetAttr(ctx.Node.Parent, "id") - } var newWidget core.Widget switch tag { @@ -219,56 +213,7 @@ func handleElement(ctx *Context) { readHTMLNode(ctx, ctx.Parent(), sublist) } case "img": - n := ctx.Node - src := GetAttr(n, "src") - alt := GetAttr(n, "alt") - if pstyle != "" { - ctx.setStyleAttr(n, pstyle) - } - // Can be either image or svg. - var img *core.Image - var svg *core.SVG - if strings.HasSuffix(src, ".svg") { - svg = New[core.SVG](ctx) - svg.SetTooltip(alt) - if pid != "" { - svg.SetName(pid) - svg.SetProperty("id", pid) - } - newWidget = svg - } else { - img = New[core.Image](ctx) - img.SetTooltip(alt) - if pid != "" { - img.SetName(pid) - img.SetProperty("id", pid) - } - newWidget = img - } - - go func() { - resp, err := Get(ctx, src) - if errors.Log(err) != nil { - return - } - defer resp.Body.Close() - if svg != nil { - svg.AsyncLock() - errors.Log(svg.Read(resp.Body)) - svg.Update() - svg.AsyncUnlock() - } else { - im, _, err := imagex.Read(resp.Body) - if err != nil { - slog.Error("error loading image", "url", src, "err", err) - return - } - img.AsyncLock() - img.SetImage(im) - img.Update() - img.AsyncUnlock() - } - }() + newWidget = handleImage(ctx, tag) case "input": ityp := GetAttr(ctx.Node, "type") val := GetAttr(ctx.Node, "value") @@ -355,6 +300,91 @@ func handleElement(ctx *Context) { } } +func handleImage(ctx *Context, tag string) core.Widget { + pid := "" + pstyle := "" + if ctx.BlockParent != nil { // these attributes get put on a block parent element + pstyle = GetAttr(ctx.Node.Parent, "style") + pid = GetAttr(ctx.Node.Parent, "id") + } + n := ctx.Node + src := GetAttr(n, "src") + alt := GetAttr(n, "alt") + if pstyle != "" { + ctx.setStyleAttr(n, pstyle) + } + var newWidget core.Widget + var resp *http.Response + var err error + if !ctx.DelayedImageLoad { + resp, err = Get(ctx, src) + if errors.Log(err) != nil { + return nil + } + defer resp.Body.Close() + } + + // Can be either image or svg. + var img *core.Image + var svg *core.SVG + if strings.HasSuffix(src, ".svg") { + svg = New[core.SVG](ctx) + svg.SetTooltip(alt) + if pid != "" { + svg.SetName(pid) + svg.SetProperty("id", pid) + } + if !ctx.DelayedImageLoad { + errors.Log(svg.Read(resp.Body)) + } + newWidget = svg + } else { + img = New[core.Image](ctx) + img.SetTooltip(alt) + if pid != "" { + img.SetName(pid) + img.SetProperty("id", pid) + } + if !ctx.DelayedImageLoad { + im, _, err := imagex.Read(resp.Body) + if err != nil { + slog.Error("error loading image", "url", src, "err", err) + } else { + img.SetImage(im) + } + } + newWidget = img + } + + if !ctx.DelayedImageLoad { + return newWidget + } + go func() { + resp, err := Get(ctx, src) + if errors.Log(err) != nil { + return + } + defer resp.Body.Close() + if svg != nil { + svg.AsyncLock() + errors.Log(svg.Read(resp.Body)) + svg.Update() + svg.AsyncUnlock() + } else { + im, _, err := imagex.Read(resp.Body) + if err != nil { + slog.Error("error loading image", "url", src, "err", err) + return + } + img.AsyncLock() + img.SetImage(im) + img.Update() + img.AsyncUnlock() + } + }() + return newWidget +} + // handleText creates a new [core.Text] from the given information, setting the text and // the text click function so that URLs are opened according to [Context.OpenURL]. func handleText(ctx *Context, tag string) *core.Text { diff --git a/paint/painter.go b/paint/painter.go index 9e94b1802a..275635e08d 100644 --- a/paint/painter.go +++ b/paint/painter.go @@ -508,39 +508,47 @@ func (pc *Painter) SetPixel(x, y int) { // using the bounds of the source image in rectangle rect, using // the given draw operration: Over = overlay (alpha blend with destination) // Src = copy source directly, overwriting destination pixels. -func (pc *Painter) DrawImage(src image.Image, rect image.Rectangle, srcStart image.Point, op draw.Op) { - pc.Render.Add(pimage.NewDraw(rect, src, srcStart, op)) +func (pc *Painter) DrawImage(src image.Image, rect image.Rectangle, srcStart image.Point, op draw.Op) *pimage.Params { + pim := pimage.NewDraw(rect, src, srcStart, op) + pc.Render.Add(pim) + return pim } // DrawImageAnchored draws the specified image at the specified anchor point. // The anchor point is x - w * ax, y - h * ay, where w, h is the size of the // image. Use ax=0.5, ay=0.5 to center the image at the specified point. -func (pc *Painter) DrawImageAnchored(src image.Image, x, y, ax, ay float32) { +func (pc *Painter) DrawImageAnchored(src image.Image, x, y, ax, ay float32) *pimage.Params { s := src.Bounds().Size() x -= ax * float32(s.X) y -= ay * float32(s.Y) m := pc.Transform().Translate(x, y) + var pim *pimage.Params if pc.Mask == nil { - pc.Render.Add(pimage.NewTransform(m, src.Bounds(), src, draw.Over)) + pim = pimage.NewTransform(m, src.Bounds(), src, draw.Over) } else { - pc.Render.Add(pimage.NewTransformMask(m, src.Bounds(), src, draw.Over, pc.Mask, image.Point{})) + pim = pimage.NewTransformMask(m, src.Bounds(), src, draw.Over, pc.Mask, image.Point{}) } + pc.Render.Add(pim) + return pim } // DrawImageScaled draws the specified image starting at given upper-left point, // such that the size of the image is rendered as specified by w, h parameters // (an additional scaling is applied to the transform matrix used in rendering) -func (pc *Painter) DrawImageScaled(src image.Image, x, y, w, h float32) { +func (pc *Painter) DrawImageScaled(src image.Image, x, y, w, h float32) *pimage.Params { s := src.Bounds().Size() isz := math32.FromPoint(s) isc := math32.Vec2(w, h).Div(isz) m := pc.Transform().Translate(x, y).Scale(isc.X, isc.Y) + var pim *pimage.Params if pc.Mask == nil { - pc.Render.Add(pimage.NewTransform(m, src.Bounds(), src, draw.Over)) + pim = pimage.NewTransform(m, src.Bounds(), src, draw.Over) } else { - pc.Render.Add(pimage.NewTransformMask(m, src.Bounds(), src, draw.Over, pc.Mask, image.Point{})) + pim = pimage.NewTransformMask(m, src.Bounds(), src, draw.Over, pc.Mask, image.Point{}) } + pc.Render.Add(pim) + return pim } // BoundingBox computes the bounding box for an element in pixel int diff --git a/paint/pdf/links.go b/paint/pdf/links.go new file mode 100644 index 0000000000..453e1166fc --- /dev/null +++ b/paint/pdf/links.go @@ -0,0 +1,53 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pdf + +import ( + "cogentcore.org/core/math32" +) + +// AddAnchor adds a uniquely-named link anchor location. +// The position is in "default user space" coordinates = standard page coordinates, +// without the current CTM transform.// This function will handle the base page +// transform for scaling and flipping of coordinates to top-left system. +func (w *pdfPage) AddAnchor(name string, pos math32.Vector2) { + ms := math32.Scale2D(w.pdf.globalScale, w.pdf.globalScale) + pos = ms.MulVector2AsPoint(pos) + if w.pdf.anchors == nil { + w.pdf.anchors = make(pdfMap) + } + // pageNo replaced at end with ref: + w.pdf.anchors[name] = pdfArray{w.pageNo, pdfName("XYZ"), 0, w.height - pos.Y, 0} + // fmt.Println("anchor:", w.pageNo, name, pos) +} + +// AddLink adds a link annotation. The rect is in "default user space" +// coordinates = standard page coordinates, without the current CTM transform. +// This function will handle the base page transform for scaling and +// flipping of coordinates to top-left system. +func (w *pdfPage) AddLink(uri string, rect math32.Box2) { + ms := math32.Scale2D(w.pdf.globalScale, w.pdf.globalScale) + rect = rect.MulMatrix2(ms) + isLocal := false + if uri[0] == '#' { // local anchor actions + uri = uri[1:] + isLocal = true + } + annot := pdfDict{ + "Type": pdfName("Annot"), + "Subtype": pdfName("Link"), + "Border": pdfArray{0, 0, 0}, + "Rect": pdfArray{rect.Min.X, w.height - rect.Max.Y, rect.Max.X, w.height - rect.Min.Y}, + } + if isLocal { + annot["Dest"] = uri + } else { + annot["A"] = pdfDict{ + "S": pdfName("URI"), + pdfName("URI"): uri, + } + } + w.annots = append(w.annots, annot) +} diff --git a/paint/pdf/page.go b/paint/pdf/page.go index 2faf53cc32..df087fb87e 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -29,6 +29,7 @@ import ( type pdfPage struct { *bytes.Buffer pdf *pdfWriter + pageNo int width, height float32 resources pdfDict annots pdfArray @@ -73,27 +74,6 @@ func (w *pdfPage) writePage(parent pdfRef) pdfRef { return w.pdf.writeObject(page) } -// AddAnnotation adds an annotation. The rect is in "default user space" -// coordinates = standard page coordinates, without the current CTM transform. -// This function will handle the base page transform for scaling and -// flipping of coordinates to top-left system. -func (w *pdfPage) AddURIAction(uri string, rect math32.Box2) { - ms := math32.Scale2D(w.pdf.globalScale, w.pdf.globalScale) - rect = rect.MulMatrix2(ms) - annot := pdfDict{ - "Type": pdfName("Annot"), - "Subtype": pdfName("Link"), - "Border": pdfArray{0, 0, 0}, - "Rect": pdfArray{rect.Min.X, w.height - rect.Max.Y, rect.Max.X, w.height - rect.Min.Y}, - "Contents": uri, - "A": pdfDict{ - "S": pdfName("URI"), - "URI": uri, - }, - } - w.annots = append(w.annots, annot) -} - // SetFill sets the fill style values where different from current. func (w *pdfPage) SetFill(fill *styles.Fill) { csty := w.style() @@ -384,63 +364,8 @@ func (w *pdfPage) WriteText(tx string) error { fmt.Fprintf(w, ")") } - // position := w.textPosition - // if glyphs, ok := TJ[0].([]canvasText.Glyph); ok && 0 < len(glyphs) && mode != ppath.HorizontalTB && !glyphs[0].Vertical { - // glyphRotation, glyphOffset := glyphs[0].Rotation(), glyphs[0].YOffset-int32(glyphs[0].SFNT.Head.UnitsPerEm/2) - // if glyphRotation != canvasText.NoRotation || glyphOffset != 0 { - // w.SetTextPosition(position.Rotate(float32(glyphRotation)).Translate(0.0, glyphs[0].Size/float32(glyphs[0].SFNT.Head.UnitsPerEm)*mmPerPt*float32(glyphOffset))) - // } - // } - - // f := 1000.0 / float32(w.font.SFNT.Head.UnitsPerEm) fmt.Fprintf(w, "[") write(tx) - - // for _, tj := range TJ { - // switch val := tj.(type) { - // case []canvasText.Glyph: - // i := 0 - // for j, glyph := range val { - // if mode == ppath.HorizontalTB || !glyph.Vertical { - // origXAdvance := int32(w.font.SFNT.GlyphAdvance(glyph.ID)) - // if glyph.XAdvance != origXAdvance { - // write(val[i : j+1]) - // fmt.Fprintf(w, " %d", -int(f*float32(glyph.XAdvance-origXAdvance)+0.5)) - // i = j + 1 - // } - // } else { - // origYAdvance := -int32(w.font.SFNT.GlyphVerticalAdvance(glyph.ID)) - // if glyph.YAdvance != origYAdvance { - // write(val[i : j+1]) - // fmt.Fprintf(w, " %d", -int(f*float32(glyph.YAdvance-origYAdvance)+0.5)) - // i = j + 1 - // } - // } - // } - // write(val[i:]) - // case string: - // i := 0 - // if mode == ppath.HorizontalTB { - // var rPrev rune - // for j, r := range val { - // if i < j { - // kern := w.font.SFNT.Kerning(w.font.SFNT.GlyphIndex(rPrev), w.font.SFNT.GlyphIndex(r)) - // if kern != 0 { - // writeString(val[i:j]) - // fmt.Fprintf(w, " %d", -int(f*float32(kern)+0.5)) - // i = j - // } - // } - // rPrev = r - // } - // } - // writeString(val[i:]) - // case float32: - // fmt.Fprintf(w, " %d", -int(val*1000.0/w.fontSize+0.5)) - // case int: - // fmt.Fprintf(w, " %d", -int(float32(val)*1000.0/w.fontSize+0.5)) - // } - // } fmt.Fprintf(w, "]TJ") return nil } diff --git a/paint/pdf/pdf.go b/paint/pdf/pdf.go index 640766873c..e4930bcdf9 100644 --- a/paint/pdf/pdf.go +++ b/paint/pdf/pdf.go @@ -100,7 +100,7 @@ func (r *PDF) NewPage(width, height float32) { // AddLink adds a link to the PDF document. func (r *PDF) AddLink(uri string, rect math32.Box2) { - r.w.AddURIAction(uri, rect) + r.w.AddLink(uri, rect) } // Close finished and closes the PDF. @@ -267,3 +267,9 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { func (r *PDF) Image(img image.Image, m math32.Matrix2) { r.w.DrawImage(img, m) } + +// AddAnchor adds a uniquely-named link anchor location, +// which can then be a target for links. +func (r *PDF) AddAnchor(name string, pos math32.Vector2) { + r.w.AddAnchor(name, pos) +} diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 4468a1eadd..00d6e2c02c 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -113,3 +113,26 @@ func TestMathDisplay(t *testing.T) { RestorePreviousFonts(prv) }) } + +func TestLinks(t *testing.T) { + RunTest(t, "links", 300, 300, func(pd *PDF, sty *styles.Paint) { + prv := UseStandardFonts() + sh := shaped.NewShaper() + m := math32.Identity2() + rsty := &sty.Font + tsty := &sty.Text + sz := math32.Vec2(250, 250) + + txt := func(src string, pos math32.Vector2) { + tx, err := htmltext.HTMLToRich([]byte(src), rsty, nil) + assert.NoError(t, err) + lns := sh.WrapLines(tx, rsty, tsty, sz) + pd.Text(sty, m, pos, lns) + } + txt("Some random text here", math32.Vec2(10, 10)) + pd.w.AddAnchor("test", math32.Vec2(10, 10)) + txt(`A link to that text`, math32.Vec2(10, 30)) + + RestorePreviousFonts(prv) + }) +} diff --git a/paint/pdf/text.go b/paint/pdf/text.go index c8491674f0..514c9c6e8e 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -24,6 +24,9 @@ import ( // Text renders text to the canvas using a transformation matrix, // (the translation component specifies the starting offset) func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, lns *shaped.Lines) { + if lns.Anchor != "" { + r.w.AddAnchor(lns.Anchor, pos) + } mt := m.Mul(math32.Translate2D(pos.X, pos.Y)) r.w.PushTransform(mt) off := lns.Offset @@ -220,6 +223,6 @@ func (r *PDF) links(lns *shaped.Lines, m math32.Matrix2, pos math32.Vector2) { } rb := srb.Translate(pos) rb = rb.MulMatrix2(m) - r.w.AddURIAction(lk.URL, rb) + r.w.AddLink(lk.URL, rb) } } diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 93b4753499..24d57f44e7 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -15,6 +15,7 @@ import ( "image" "io" "math" + "slices" "sort" "strings" "time" @@ -26,6 +27,7 @@ import ( "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" "cogentcore.org/core/text/text" + "golang.org/x/exp/maps" ) // TODO: Invalid graphics transparency, Group has a transparency S entry or the S entry is null @@ -49,6 +51,7 @@ type pdfWriter struct { // fontsV map[*text.Font]pdfRef images map[image.Image]pdfRef layers pdfLayers + anchors pdfMap // things that can be linked to within doc compress bool subset bool title string @@ -76,7 +79,7 @@ func newPDFWriter(writer io.Writer, un *units.Context) *pdfWriter { w.globalScale = w.unitContext.Convert(1, units.UnitDot, units.UnitPt) - w.write("%%PDF-1.7\n") + w.write("%%PDF-1.7\n%%Ŧǟċơ\n") return w } @@ -142,6 +145,7 @@ type pdfRef int type pdfName string type pdfArray []interface{} type pdfDict map[pdfName]interface{} +type pdfMap map[string]interface{} type pdfFilter string type pdfStream struct { dict pdfDict @@ -177,10 +181,7 @@ func (w *pdfWriter) writeVal(i interface{}) { case float64: w.write("%v", dec(v)) case string: - v = strings.Replace(v, `\`, `\\`, -1) - v = strings.Replace(v, `(`, `\(`, -1) - v = strings.Replace(v, `)`, `\)`, -1) - w.write("(%v)", v) + w.write("(%v)", escape(v)) case pdfRef: w.write("%v 0 R", v) case pdfName, pdfFilter: @@ -210,9 +211,16 @@ func (w *pdfWriter) writeVal(i interface{}) { } w.writeVal(val) } + if val, ok := v["S"]; ok { + w.write("/S") + if pdfValContinuesName(val) { + w.write(" ") + } + w.writeVal(val) + } keys := []string{} for key := range v { - if key != "Type" && key != "Subtype" { + if key != "Type" && key != "Subtype" && key != "S" { keys = append(keys, string(key)) } } @@ -225,6 +233,20 @@ func (w *pdfWriter) writeVal(i interface{}) { w.writeVal(v[pdfName(key)]) } w.write(">>") + case pdfMap: + w.write("<<") + keys := maps.Keys(v) + sort.Strings(keys) + nk := len(keys) + for i, key := range keys { + w.writeVal(pdfName(key)) + w.write(" ") + w.writeVal(v[key]) + if i < nk-1 { + w.write(" ") + } + } + w.write(">>") case pdfStream: if v.dict == nil { v.dict = pdfDict{} @@ -270,7 +292,7 @@ func (w *pdfWriter) writeVal(i interface{}) { v.objNum = len(w.objOffsets) w.write("<>", pdfName(v.name)) default: - panic(fmt.Sprintf("unknown PDF type %T", i)) + // panic(fmt.Sprintf("unknown PDF type %T", i)) } } @@ -360,7 +382,6 @@ func (w *pdfWriter) getFont(sty *rich.Style, tsty *text.Style) pdfRef { // Close finished the document. func (w *pdfWriter) Close() error { - // TODO: support cross reference table streams and compressed objects for all dicts if w.page != nil { w.pages = append(w.pages, w.page.writePage(pdfRef(3))) } @@ -378,7 +399,25 @@ func (w *pdfWriter) Close() error { catalog := pdfDict{ "Type": pdfName("Catalog"), "Pages": pdfRef(3), - // TODO: add metadata? + } + + if len(w.anchors) > 0 { + ancrefs := pdfMap{} + var nms []string + for nm, v := range w.anchors { + vary := v.(pdfArray) + vary[0] = w.pages[vary[0].(int)] // replace page no with ref + anc := w.writeObject(pdfDict{"D": vary}) + ancrefs[nm] = anc + nms = append(nms, nm) + } + slices.Sort(nms) + var nmary pdfArray + for _, nm := range nms { + nmary = append(nmary, nm, ancrefs[nm]) + } + nmref := w.writeObject(pdfDict{"Names": nmary}) + catalog[pdfName("Names")] = pdfDict{"Dests": nmref} } // document info @@ -481,6 +520,7 @@ func (w *pdfWriter) NewPage(width, height float32) *pdfPage { pdf: w, width: width, height: height, + pageNo: len(w.pages), resources: pdfDict{}, graphicsStates: map[float32]pdfName{}, inTextObject: false, @@ -490,6 +530,7 @@ func (w *pdfWriter) NewPage(width, height float32) *pdfPage { } w.page.stack.Push(newContext(styles.NewPaint(), math32.Identity2())) w.page.setTopTransform() + // fmt.Println("added page:", w.page.pageNo) return w.page } @@ -519,3 +560,50 @@ func (f dec) String() string { func mat2(m math32.Matrix2) string { return fmt.Sprintf("%v %v %v %v %v %v", dec(m.XX), dec(m.XY), dec(m.YX), dec(m.YY), dec(m.X0), dec(m.Y0)) } + +// Escape special characters in strings +func escape(s string) string { + s = strings.Replace(s, "\\", "\\\\", -1) + s = strings.Replace(s, "(", "\\(", -1) + s = strings.Replace(s, ")", "\\)", -1) + s = strings.Replace(s, "\r", "\\r", -1) + return s +} + +// utf8toutf16 converts UTF-8 to UTF-16BE; from http://www.fpdf.org/ +func utf8toutf16(s string, withBOM ...bool) string { + bom := true + if len(withBOM) > 0 { + bom = withBOM[0] + } + res := make([]byte, 0, 8) + if bom { + res = append(res, 0xFE, 0xFF) + } + nb := len(s) + i := 0 + for i < nb { + c1 := byte(s[i]) + i++ + switch { + case c1 >= 224: + // 3-byte character + c2 := byte(s[i]) + i++ + c3 := byte(s[i]) + i++ + res = append(res, ((c1&0x0F)<<4)+((c2&0x3C)>>2), + ((c2&0x03)<<6)+(c3&0x3F)) + case c1 >= 192: + // 2-byte character + c2 := byte(s[i]) + i++ + res = append(res, ((c1 & 0x1C) >> 2), + ((c1&0x03)<<6)+(c2&0x3F)) + default: + // Single-byte character + res = append(res, 0, c1) + } + } + return string(res) +} diff --git a/paint/pimage/pimage.go b/paint/pimage/pimage.go index 8225ced717..6068849b5f 100644 --- a/paint/pimage/pimage.go +++ b/paint/pimage/pimage.go @@ -65,6 +65,11 @@ type Params struct { // BlurRadius is the Gaussian standard deviation for Blur function BlurRadius float32 + + // Anchor provides a named link destination associated with this text item. + // Used for document navigation, e.g., in PDF rendering. Must be a unique name. + // Is set in [core.Text] based on the id property, which is set by [content]. + Anchor string } func (pr *Params) IsRenderItem() {} diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index 5a191f80ae..c42b8d64b3 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -156,6 +156,10 @@ func (rs *Renderer) RenderImage(pr *pimage.Params) { pr.Rect = image.Rectangle{Max: rs.size.ToPoint()} } + if pr.Anchor != "" { + rs.PDF.AddAnchor(pr.Anchor, math32.FromPoint(pr.Rect.Min)) + } + // todo: handle masks! // Fast path for [image.Uniform] diff --git a/text/shaped/lines.go b/text/shaped/lines.go index dca22faeed..876682d7b4 100644 --- a/text/shaped/lines.go +++ b/text/shaped/lines.go @@ -65,6 +65,11 @@ type Lines struct { // HighlightColor is the color to use for rendering highlighted regions. HighlightColor image.Image + + // Anchor provides a named link destination associated with this text item. + // Used for document navigation, e.g., in PDF rendering. Must be a unique name. + // Is set in [core.Text] based on the id property, which is set by [content]. + Anchor string } // Line is one line of shaped text, containing multiple Runs. From 9e7ab299656c0d9f979bc3c9b2c311dea2cdcace Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 09:59:35 +0200 Subject: [PATCH 41/99] pdf: content set DelayedImageLoad = false regardless of current default, in case it changes --- content/content.go | 1 + 1 file changed, 1 insertion(+) diff --git a/content/content.go b/content/content.go index 343fd4b60a..157c65f2ca 100644 --- a/content/content.go +++ b/content/content.go @@ -129,6 +129,7 @@ func (ct *Content) Init() { ct.SetSplits(0.2, 0.8) ct.Context = htmlcore.NewContext() + ct.Context.DelayedImageLoad = false // not useful for content ct.Context.OpenURL = func(url string) { ct.Open(url) } From d79315cf05889553e73909bfcb3e1f8f3d549f52 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 12:11:41 +0200 Subject: [PATCH 42/99] pdf: csl generates html formatted text for references, including links etc. all good. --- content/content.go | 5 +++++ text/csl/apa.go | 6 +++--- text/csl/keylist.go | 6 ++++-- text/csl/md.go | 4 +++- text/htmltext/tohtml.go | 27 ++++++++++++++++++++------- text/paginate/pdf.go | 5 ----- 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/content/content.go b/content/content.go index 157c65f2ca..1afe30dd26 100644 --- a/content/content.go +++ b/content/content.go @@ -527,12 +527,17 @@ func (ct *Content) PageRefs(page *bcontent.Page) *core.Frame { if errors.Log(err) != nil { return nil } + // os.WriteFile("tmp-refs.md", b.Bytes(), 0666) fr := core.NewFrame() + fr.Styler(func(s *styles.Style) { + s.Direction = styles.Column + }) err = htmlcore.ReadMD(ct.Context, fr, b.Bytes()) if errors.Log(err) != nil { return nil } + fr.StyleTree() fr.SetScene(ct.Scene) return fr } diff --git a/text/csl/apa.go b/text/csl/apa.go index d1278e4c67..a25ac8e63d 100644 --- a/text/csl/apa.go +++ b/text/csl/apa.go @@ -72,8 +72,8 @@ func RefLinks(it *Item, tx *rich.Text) { tx.AddLink(link, it.URL, it.URL) } if it.DOI != "" { - url := " http://doi.org/" + it.DOI - tx.AddLink(link, url, url) + url := "http://doi.org/" + it.DOI + tx.AddLink(link, url, " "+url) } } @@ -118,7 +118,7 @@ func RefAPABook(it *Item) rich.Text { } } if it.Publisher != "" { - tx.AddSpanString(sty, EnsurePeriod(it.Publisher)+" ") + tx.AddSpanString(sty, " "+EnsurePeriod(it.Publisher)+" ") } RefLinks(it, &tx) return tx diff --git a/text/csl/keylist.go b/text/csl/keylist.go index 69415be250..fdb08e7f58 100644 --- a/text/csl/keylist.go +++ b/text/csl/keylist.go @@ -28,10 +28,12 @@ func NewKeyList(items []Item) *KeyList { return kl } -// AlphaKeys returns an alphabetically sorted list of keys. +// AlphaKeys returns an alphabetically sorted list of keys (case insensitive). func (kl *KeyList) AlphaKeys() []string { ks := slices.Clone(kl.Keys) - slices.Sort(ks) + slices.SortFunc(ks, func(a, b string) int { + return strings.Compare(strings.ToLower(a), strings.ToLower(b)) + }) return ks } diff --git a/text/csl/md.go b/text/csl/md.go index ed5d0d2a72..00d5c10a3b 100644 --- a/text/csl/md.go +++ b/text/csl/md.go @@ -12,6 +12,7 @@ import ( "strings" "cogentcore.org/core/base/errors" + "cogentcore.org/core/text/htmltext" ) // GenerateMarkdown extracts markdown citations in the format [@Ref; @Ref] @@ -88,7 +89,8 @@ func WriteRefsMarkdown(w io.Writer, kl *KeyList, sty Styles) error { if err != nil { return err } - _, err = w.Write([]byte(string(ref.Join()) + "

\n\n")) // todo: ref to markdown!! + str := htmltext.RichToHTML(ref) + _, err = w.Write([]byte(str + "

\n\n")) // todo: ref to markdown!! if err != nil { return err } diff --git a/text/htmltext/tohtml.go b/text/htmltext/tohtml.go index 488bb64446..93375640c4 100644 --- a/text/htmltext/tohtml.go +++ b/text/htmltext/tohtml.go @@ -14,23 +14,27 @@ import ( func RichToHTML(tx rich.Text) string { var b strings.Builder ns := tx.NumSpans() - var lsty *rich.Style + lsty := rich.NewStyle() for si := range ns { sty, rs := tx.Span(si) var stags, etags string - if sty.Weight != rich.Normal && (lsty == nil || lsty.Weight != sty.Weight) { + if sty.Special == rich.Link { + b.WriteString(`` + string(rs) + ``) + continue + } + if sty.Weight != rich.Normal && lsty.Weight != sty.Weight { stags += "<" + sty.Weight.HTMLTag() + ">" - } else if sty.Weight == rich.Normal && (lsty != nil && lsty.Weight != sty.Weight) { + } else if sty.Weight == rich.Normal && lsty.Weight != sty.Weight { etags += "" } - if sty.Slant != rich.SlantNormal && (lsty == nil || lsty.Slant != sty.Slant) { + if sty.Slant != rich.SlantNormal && lsty.Slant != sty.Slant { stags += "" - } else if sty.Slant == rich.SlantNormal && lsty != nil && lsty.Slant != sty.Slant { + } else if sty.Slant == rich.SlantNormal && lsty.Slant != sty.Slant { etags += "" } - if sty.Decoration.HasFlag(rich.Underline) && (lsty == nil || !lsty.Decoration.HasFlag(rich.Underline)) { + if sty.Decoration.HasFlag(rich.Underline) && !lsty.Decoration.HasFlag(rich.Underline) { stags += "" - } else if !sty.Decoration.HasFlag(rich.Underline) && lsty != nil && lsty.Decoration.HasFlag(rich.Underline) { + } else if !sty.Decoration.HasFlag(rich.Underline) && lsty.Decoration.HasFlag(rich.Underline) { etags += "" } b.WriteString(etags) @@ -38,5 +42,14 @@ func RichToHTML(tx rich.Text) string { b.WriteString(string(rs)) lsty = sty } + if lsty.Slant == rich.Italic { + b.WriteString("") + } + if lsty.Weight != rich.Normal { + b.WriteString("") + } + if lsty.Decoration.HasFlag(rich.Underline) { + b.WriteString("") + } return b.String() } diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index 812d6ee0cf..d91cadd825 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -80,7 +80,6 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { // only for full format rendering (e.g., PDF) func (p *pager) assemble() { sc := core.AsWidget(p.ins[0]).Scene - // sc.AsyncLock() if p.opts.Title != nil { tf := core.NewFrame() tf.Scene = sc @@ -111,9 +110,5 @@ func (p *pager) assemble() { } return true }) - - iw.Scene.StyleTree() - iw.Scene.LayoutRenderScene() } - // sc.AsyncUnlock() } From e3fa632ec9da923b9f78d1f70e85901b59a119f1 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 12:31:04 +0200 Subject: [PATCH 43/99] pdf: support markdown in figure captions. --- content/handlers.go | 3 ++- htmlcore/md.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/content/handlers.go b/content/handlers.go index 372d65a9a1..45ea7c6f9d 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -243,8 +243,9 @@ func (ct *Content) widgetHandlerFigure(w core.Widget, id string) { if !fig { return } + altf := htmlcore.MDToHTML(ct.Context, []byte(alt)) lbl := ct.currentPage.SpecialLabel(id) - lbf := "" + lbl + ": " + alt + "

" + lbf := "" + lbl + ": " + string(altf) + "

" ct.moveToBlockFrame(w, id, lbf, false) } diff --git a/htmlcore/md.go b/htmlcore/md.go index ca025bbd68..d0529862e9 100644 --- a/htmlcore/md.go +++ b/htmlcore/md.go @@ -18,7 +18,7 @@ import ( var divRegex = regexp.MustCompile("

") -func mdToHTML(ctx *Context, md []byte) []byte { +func MDToHTML(ctx *Context, md []byte) []byte { // create markdown parser with extensions extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.Attributes | parser.Mmark p := parser.NewWithExtensions(extensions) @@ -43,7 +43,7 @@ func mdToHTML(ctx *Context, md []byte) []byte { // ReadMD reads MD (markdown) from the given bytes and adds corresponding // Cogent Core widgets to the given [core.Widget], using the given context. func ReadMD(ctx *Context, parent core.Widget, b []byte) error { - htm := mdToHTML(ctx, b) + htm := MDToHTML(ctx, b) // os.WriteFile("htmlcore_tmp.html", htm, 0666) // note: keep here, needed for debugging buf := bytes.NewBuffer(htm) return ReadHTML(ctx, parent, buf) From 77499310ebd85ca3e439a07a2c33aa2b6708589c Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 14:44:43 +0200 Subject: [PATCH 44/99] pdf: fixed remaining pdf incompatibility issues: now fully compliant! layer stuff was very broken -- now has a test but is not used by default b/c basically meaningless. but is there as an option. last major issue is the gradients (and then embedded fonts eventually) --- content/settings.go | 1 - paint/pdf/layer.go | 84 +++++++++----------------- paint/pdf/page.go | 4 +- paint/pdf/pdf.go | 44 ++++++++++---- paint/pdf/pdf_test.go | 25 ++++++++ paint/pdf/writer.go | 22 +++++-- paint/renderers/pdfrender/pdfrender.go | 32 +++++----- 7 files changed, 123 insertions(+), 89 deletions(-) diff --git a/content/settings.go b/content/settings.go index b993e76497..d2e8183381 100644 --- a/content/settings.go +++ b/content/settings.go @@ -47,7 +47,6 @@ func (s *SettingsData) Defaults() { pt = ps.SiteTitle + ": " + pt } ps.PDF.Header = paginate.NoFirst(paginate.HeaderLeftPageNumber(pt)) - // todo: add affiliations and abstract ur := ct.getPrintURL() + "/" + curPage.URL ura := `` + ur + `` dt := "" diff --git a/paint/pdf/layer.go b/paint/pdf/layer.go index e2e7879ee1..8132a86f41 100644 --- a/paint/pdf/layer.go +++ b/paint/pdf/layer.go @@ -14,7 +14,8 @@ import "fmt" type pdfLayer struct { name string visible bool - objNum int // object number + index int + ref pdfRef } // pdfLayers is all the layers @@ -33,33 +34,43 @@ func (w *pdfWriter) layerInit() { // AddLayer defines a layer that can be shown or hidden when the document is // displayed. name specifies the layer name that the document reader will // display in the layer list. visible specifies whether the layer will be -// initially visible. The return value is an integer ID that is used in a call -// to BeginLayer(). -func (w *pdfWriter) AddLayer(name string, visible bool) (layerID int) { - layerID = len(w.layers.list) - w.layers.list = append(w.layers.list, pdfLayer{name: name, visible: visible}) - return +// initially visible. The return value is unique layer index that must be +// used in subsequent calls to BeginLayer. +func (w *pdfWriter) AddLayer(name string, visible bool) int { + layno := len(w.layers.list) + lay := pdfLayer{name: name, visible: visible, index: layno} + lay.ref = w.writeObject(pdfDict{"Type": pdfName("OCG"), "Name": name}) + w.layers.list = append(w.layers.list, lay) + return layno } -// BeginLayer is called to begin adding content to the specified layer. All -// content added to the page between a call to BeginLayer and a call to -// EndLayer is added to the layer specified by id. See AddLayer for more -// details. -func (w *pdfWriter) BeginLayer(id int) { +// BeginLayer is called to begin adding content to the specified layer index +// in a given page. Layer must have already been added via AddLayer. +// All content added to the page between a call to BeginLayer and a call to +// EndLayer is added to the layer specified by id. +func (w *pdfPage) BeginLayer(id int) { w.EndLayer() - if id >= 0 && id < len(w.layers.list) { - w.write("/OC /OC%d BDC", id) - w.layers.currentLayer = id + if id < 0 || id >= len(w.pdf.layers.list) { + return + } + ocn := fmt.Sprintf("oc%d", id) + fmt.Fprintf(w, " /OC /%s BDC", ocn) + w.pdf.layers.currentLayer = id + if _, ok := w.resources["Properties"]; !ok { + w.resources["Properties"] = pdfDict{} } + l := &w.pdf.layers.list[id] + w.resources["Properties"].(pdfDict)[pdfName(ocn)] = l.ref } // EndLayer is called to stop adding content to the currently active layer. // See BeginLayer for more details. -func (w *pdfWriter) EndLayer() { - if w.layers.currentLayer >= 0 { - w.write(" EMC") - w.layers.currentLayer = -1 +func (w *pdfPage) EndLayer() { + if w.pdf.layers.currentLayer < 0 { + return } + fmt.Fprintf(w, " EMC") + w.pdf.layers.currentLayer = -1 } // OpenLayerPane advises the document reader to open the layer pane when the @@ -67,38 +78,3 @@ func (w *pdfWriter) EndLayer() { func (w *pdfWriter) OpenLayerPane() { w.layers.openLayerPane = true } - -func (w *pdfWriter) writeLayers() { - for _, l := range w.layers.list { - w.writeObject(&l) - } -} - -func (w *pdfWriter) writeLayerResourceDict() { - if len(w.layers.list) == 0 { - return - } - w.write("/Properties <<") - for j, layer := range w.layers.list { - w.write("/OC%d %d 0 R", j, layer.objNum) - } - w.write(">>") -} - -func (w *pdfWriter) writeLayerCatalog() { - if len(w.layers.list) == 0 { - return - } - onStr := "" - offStr := "" - for _, layer := range w.layers.list { - onStr += fmt.Sprintf("%d 0 R ", layer.objNum) - if !layer.visible { - offStr += fmt.Sprintf("%d 0 R ", layer.objNum) - } - } - w.write("/OCProperties <>>>", onStr, offStr, onStr) - if w.layers.openLayerPane { - w.write("/PageMode /UseOC") - } -} diff --git a/paint/pdf/page.go b/paint/pdf/page.go index df087fb87e..66d12fba33 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -103,7 +103,9 @@ func (w *pdfPage) SetFillColor(fill *styles.Fill) { clr = colors.ApplyOpacity(colors.AsRGBA(x), fill.Opacity) } a := float32(clr.A) / 255.0 - if clr.R == clr.G && clr.R == clr.B { + if a == 0 { + fmt.Fprintf(w, " 0 g") + } else if clr.R == clr.G && clr.R == clr.B { fmt.Fprintf(w, " %v g", dec(float32(clr.R)/255.0/a)) } else { fmt.Fprintf(w, " %v %v %v rg", dec(float32(clr.R)/255.0/a), dec(float32(clr.G)/255.0/a), dec(float32(clr.B)/255.0/a)) diff --git a/paint/pdf/pdf.go b/paint/pdf/pdf.go index e4930bcdf9..f1efff2347 100644 --- a/paint/pdf/pdf.go +++ b/paint/pdf/pdf.go @@ -122,24 +122,42 @@ func (r *PDF) AddLayer(name string, visible bool) (layerID int) { return r.w.pdf.AddLayer(name, visible) } -// BeginLayer is called to begin adding content to the specified layer. All -// content added to the page between a call to BeginLayer and a call to +// BeginLayer is called to begin adding content to the specified layer. +// All content added to the page between a call to BeginLayer and a call to // EndLayer is added to the layer specified by id. See AddLayer for more -// details. Also adds a graphics stack push, and sets the given transform -// matrix, if not identity. -func (r *PDF) BeginLayer(id int, m math32.Matrix2) { - r.w.pdf.BeginLayer(id) - r.w.PushStack() - if !m.IsIdentity() { - r.w.SetTransform(m) - } +// details. +func (r *PDF) BeginLayer(id int) { + r.w.BeginLayer(id) } -// EndLayer is called to stop adding content to the currently active layer. See -// BeginLayer for more details. +// EndLayer is called to stop adding content to the currently active layer. +// See BeginLayer for more details. func (r *PDF) EndLayer() { + r.w.EndLayer() +} + +// PushStack adds a graphics stack push, which must +// be paired with a corresponding Pop. +func (r *PDF) PushStack() { + r.w.PushStack() +} + +// PopStack adds a graphics stack pop which must +// be paired with a corresponding Push. +func (r *PDF) PopStack() { r.w.PopStack() - r.w.pdf.EndLayer() +} + +// SetTransform adds a cm to set the current matrix transform (CMT). +func (r *PDF) SetTransform(m math32.Matrix2) { + r.w.SetTransform(m) +} + +// PushTransform adds a graphics stack push (q) and then +// cm to set the current matrix transform (CMT). +func (r *PDF) PushTransform(m math32.Matrix2) { + r.PushStack() + r.SetTransform(m) } // Path renders a path to the canvas using a style and a transformation matrix. diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 00d6e2c02c..0a37b0039c 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -136,3 +136,28 @@ func TestLinks(t *testing.T) { RestorePreviousFonts(prv) }) } + +func TestLayers(t *testing.T) { + RunTest(t, "layers", 300, 300, func(pd *PDF, sty *styles.Paint) { + prv := UseStandardFonts() + sh := shaped.NewShaper() + + src := "PDF can put HTML
formatted Text where you want" + rsty := &sty.Font + tsty := &sty.Text + + tx, err := htmltext.HTMLToRich([]byte(src), rsty, nil) + // fmt.Println(tx) + assert.NoError(t, err) + lns := sh.WrapLines(tx, rsty, tsty, math32.Vec2(250, 250)) + + // mi := math32.Identity2() + mr := math32.Rotate2D(math32.DegToRad(15)) + + g := pd.AddLayer("first layer", true) + pd.BeginLayer(g) + pd.Text(sty, mr, math32.Vec2(20, 20), lns) + pd.EndLayer() + RestorePreviousFonts(prv) + }) +} diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 24d57f44e7..0ec17e6f29 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -288,9 +288,6 @@ func (w *pdfWriter) writeVal(i interface{}) { w.write("stream\n") w.writeBytes(b) w.write("\nendstream\n") - case *pdfLayer: - v.objNum = len(w.objOffsets) - w.write("<>", pdfName(v.name)) default: // panic(fmt.Sprintf("unknown PDF type %T", i)) } @@ -420,6 +417,23 @@ func (w *pdfWriter) Close() error { catalog[pdfName("Names")] = pdfDict{"Dests": nmref} } + if len(w.layers.list) > 0 { + var refs, off pdfArray + for _, l := range w.layers.list { + refs = append(refs, l.ref) + if !l.visible { + off = append(off, l.ref) + } + } + catalog[pdfName("OCProperties")] = pdfDict{ + "OCGs": refs, + "D": pdfDict{"OFF": off, "Order": refs}, + } + if w.layers.openLayerPane { + catalog[pdfName("PageMode")] = pdfName("UseOC") + } + } + // document info info := pdfDict{ "Producer": "cogentcore/pdf", @@ -472,14 +486,12 @@ func (w *pdfWriter) Close() error { w.objOffsets[0] = w.pos w.write("%v 0 obj\n", 1) w.writeVal(catalog) - w.writeLayerCatalog() w.write("\nendobj\n") // document info w.objOffsets[1] = w.pos w.write("%v 0 obj\n", 2) w.writeVal(info) - w.writeLayerResourceDict() w.write("\nendobj\n") // page tree diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index c42b8d64b3..a029b83e0d 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -8,7 +8,6 @@ import ( "bytes" "image" "io" - "strconv" "cogentcore.org/core/base/iox/imagex" "cogentcore.org/core/base/stack" @@ -30,8 +29,8 @@ type Renderer struct { buff *bytes.Buffer - // lyStack is a stack of layers used while building the pdf (int layer id) - lyStack stack.Stack[int] + // gStack is a stack of graphic state context in the PDF + gStack stack.Stack[int] } func New(size math32.Vector2, un *units.Context) render.Renderer { @@ -77,7 +76,7 @@ func (rs *Renderer) StartRender(w io.Writer) { sx := rs.unitContext.Convert(float32(rs.size.X), rs.sizeUnits, units.UnitPt) sy := rs.unitContext.Convert(float32(rs.size.Y), rs.sizeUnits, units.UnitPt) rs.PDF = pdf.New(w, sx, sy, &rs.unitContext) - rs.lyStack = nil + rs.gStack = nil } // EndRender finishes the render @@ -110,18 +109,21 @@ func (rs *Renderer) RenderPage(r render.Render) { } } -func (rs *Renderer) PushLayer(m math32.Matrix2) int { - cg := rs.lyStack.Peek() - nm := strconv.Itoa(cg + 1) - g := rs.PDF.AddLayer(nm, true) - rs.PDF.BeginLayer(g, m) - rs.lyStack.Push(g) +func (rs *Renderer) PushTransform(m math32.Matrix2) int { + cg := rs.gStack.Peek() + g := cg + 1 + if m.IsIdentity() { + rs.PDF.PushStack() + } else { + rs.PDF.PushTransform(m) + } + rs.gStack.Push(g) return g } -func (rs *Renderer) PopLayer() int { - cg := rs.lyStack.Pop() - rs.PDF.EndLayer() +func (rs *Renderer) PopStack() int { + cg := rs.gStack.Pop() + rs.PDF.PopStack() return cg } @@ -132,11 +134,11 @@ func (rs *Renderer) RenderPath(pt *render.Path) { } func (rs *Renderer) PushContext(pt *render.ContextPush) { - rs.PushLayer(pt.Context.Transform) + rs.PushTransform(pt.Context.Transform) } func (rs *Renderer) PopContext(pt *render.ContextPop) { - rs.PopLayer() + rs.PopStack() } func (rs *Renderer) RenderText(pt *render.Text) { From 76471ba7af1c788932094f36f02c4f7fee117258 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 15:02:35 +0200 Subject: [PATCH 45/99] pdf: README with validator link --- paint/pdf/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 paint/pdf/README.md diff --git a/paint/pdf/README.md b/paint/pdf/README.md new file mode 100644 index 0000000000..ab60489d9f --- /dev/null +++ b/paint/pdf/README.md @@ -0,0 +1,9 @@ +# PDF rendering + +This package adapts the PDF rendering code from https://github.com/tdewolff/canvas (Copyright (c) 2015 Taco de Wolff, under an MIT License.) to the Cogent Core rendering framework. It is used for rendering PDFs of content documentation, producing publication-ready output from markdown input. It is also used by the Cogent Canvas SVG drawing app to render PDFs of SVGs. + + +## Helpful links + +* Validator: https://www.pdf-online.com/osa/validate.aspx + From c4dc38c832269efa31a4c08350da77bf2708c5b4 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 16:16:04 +0200 Subject: [PATCH 46/99] use ImageRenderer in pdf -- key for web --- core/events.go | 2 +- core/render_notjs.go | 2 +- core/rendersource.go | 4 ++-- core/scene.go | 10 +++++----- paint/pdf/README.md | 2 ++ text/paginate/layout.go | 7 +------ text/paginate/paginate.go | 22 ++++++++++++++++++++++ text/paginate/pdf.go | 7 +------ 8 files changed, 35 insertions(+), 21 deletions(-) diff --git a/core/events.go b/core/events.go index 399f52261c..9d3fc70f83 100644 --- a/core/events.go +++ b/core/events.go @@ -1190,7 +1190,7 @@ func (em *Events) managerKeyChordEvents(e events.Event) { e.SetHandled() } case keymap.WinSnapshot: - img := sc.renderer.Image() + img := sc.Renderer.Image() dstr := time.Now().Format(time.DateOnly + "-" + "15-04-05") var sz string if img != nil { diff --git a/core/render_notjs.go b/core/render_notjs.go index e159d98c23..c03fb03b41 100644 --- a/core/render_notjs.go +++ b/core/render_notjs.go @@ -20,7 +20,7 @@ import ( // If it returns nil, then the image could not be fetched. func grabRenderFrom(w Widget) *image.RGBA { wb := w.AsWidget() - scimg := wb.Scene.renderer.Image() + scimg := wb.Scene.Renderer.Image() if scimg == nil { return nil } diff --git a/core/rendersource.go b/core/rendersource.go index 42943e7500..b7f9bffa52 100644 --- a/core/rendersource.go +++ b/core/rendersource.go @@ -20,11 +20,11 @@ import ( // SceneSource returns a [composer.Source] for the given scene // using the given suggested draw operation. func SceneSource(sc *Scene, op draw.Op) composer.Source { - if sc.Painter.State == nil || sc.renderer == nil { + if sc.Painter.State == nil || sc.Renderer == nil { return nil } render := sc.Painter.RenderDone() - return &paintSource{render: render, renderer: sc.renderer, drawOp: op, drawPos: sc.SceneGeom.Pos} + return &paintSource{render: render, renderer: sc.Renderer, drawOp: op, drawPos: sc.SceneGeom.Pos} } // paintSource is the [composer.Source] for [paint.Painter] content, such as for a [Scene]. diff --git a/core/scene.go b/core/scene.go index 01727c1644..8a5d271df6 100644 --- a/core/scene.go +++ b/core/scene.go @@ -86,7 +86,7 @@ type Scene struct { //core:no-new selectedWidgetChan chan Widget `json:"-" xml:"-"` // source renderer for rendering the scene - renderer render.Renderer `copier:"-" json:"-" xml:"-" display:"-" set:"-"` + Renderer render.Renderer `copier:"-" json:"-" xml:"-" display:"-" set:"-"` // lastRender captures key params from last render. // If different then a new ApplyStyleScene is needed. @@ -305,8 +305,8 @@ func (sc *Scene) Resize(geom math32.Geom2DInt) bool { sc.Painter.Paint.UnitContext = sc.Styles.UnitContext } sc.SceneGeom.Pos = geom.Pos - if sc.renderer != nil { - img := sc.renderer.Image() + if sc.Renderer != nil { + img := sc.Renderer.Image() if img != nil { isz := img.Bounds().Size() if isz == geom.Size { @@ -314,11 +314,11 @@ func (sc *Scene) Resize(geom math32.Geom2DInt) bool { } } } else { - sc.renderer = paint.NewSourceRenderer(sz) + sc.Renderer = paint.NewSourceRenderer(sz) } sc.Painter.Paint.UnitContext = sc.Styles.UnitContext sc.Painter.State.Init(sc.Painter.Paint, sz) - sc.renderer.SetSize(units.UnitDot, sz) + sc.Renderer.SetSize(units.UnitDot, sz) sc.SceneGeom.Size = geom.Size // make sure sc.updateScene() diff --git a/paint/pdf/README.md b/paint/pdf/README.md index ab60489d9f..294eca8de7 100644 --- a/paint/pdf/README.md +++ b/paint/pdf/README.md @@ -5,5 +5,7 @@ This package adapts the PDF rendering code from https://github.com/tdewolff/canv ## Helpful links +* Current 2.0 Standard: https://pdfa.org/sponsored-standards/ +* Version 1.2: https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.2.pdf * Validator: https://www.pdf-online.com/osa/validate.aspx diff --git a/text/paginate/layout.go b/text/paginate/layout.go index 6df9d26aa5..85df16c464 100644 --- a/text/paginate/layout.go +++ b/text/paginate/layout.go @@ -116,12 +116,7 @@ func (p *pager) preRender(its []*item) { page, body = fr, fr return }) - sc := core.NewScene() - sz := math32.Geom2DInt{} - sz.Size = p.opts.SizeDots.ToPointCeil() - sc.Resize(sz) - sc.MakeTextShaper() - + sc := p.offScene() tree.MoveToParent(op[0], sc) op[0].SetScene(sc) sc.StyleTree() diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index 28dc11d75c..8e5181aac3 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -6,6 +6,8 @@ package paginate import ( "cogentcore.org/core/core" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint" "cogentcore.org/core/styles/units" _ "cogentcore.org/core/text/tex" ) @@ -32,6 +34,7 @@ type pager struct { outs []*core.Frame ctx units.Context + sc *core.Scene } // optsUpdate updates the option sizes based on unit context in first input. @@ -46,3 +49,22 @@ func (p *pager) paginate() { its := p.extract() p.layout(its) } + +// offScene returns a scene suitable for offline rendering, +// sized to the full page size. +func (p *pager) offScene() *core.Scene { + if p.sc != nil { + p.sc.DeleteChildren() + return p.sc + } + sz := p.opts.SizeDots + sc := core.NewScene() + // critical to be image not htmlcanvas, and not to be the correct size yet.. + sc.Renderer = paint.NewImageRenderer(sz.SubScalar(1)) + scsz := math32.Geom2DInt{} + scsz.Size = sz.ToPointCeil() + sc.Resize(scsz) + sc.MakeTextShaper() + p.sc = sc + return sc +} diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index d91cadd825..eb53c012d1 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -8,7 +8,6 @@ import ( "io" "cogentcore.org/core/core" - "cogentcore.org/core/math32" "cogentcore.org/core/paint" "cogentcore.org/core/paint/pdf" "cogentcore.org/core/paint/renderers/pdfrender" @@ -36,11 +35,7 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { p.assemble() p.paginate() - sc := core.NewScene() - sz := math32.Geom2DInt{} - sz.Size = opts.SizeDots.ToPointCeil() - sc.Resize(sz) - sc.MakeTextShaper() + sc := p.offScene() pdr := paint.NewPDFRenderer(opts.SizeDots, &p.ctx).(*pdfrender.Renderer) pdr.StartRender(w) From a6148817eb35f8edbcf2a44fc4e4bd374fe5983d Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 16:24:34 +0200 Subject: [PATCH 47/99] pdf: use the right base url --- content/url_js.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/content/url_js.go b/content/url_js.go index c8b194b5b0..0b885b455a 100644 --- a/content/url_js.go +++ b/content/url_js.go @@ -26,7 +26,10 @@ var ( OfflineURL = "" ) -func (ct *Content) getPrintURL() string { return ct.getWebURL() } +func (ct *Content) getPrintURL() string { + p := originalBase.String() + return p[:len(p)-1] +} // getWebURL returns the current relative web URL that should be passed to [Content.Open] // on startup and in [Content.handleWebPopState]. From 368bd53c8cc8df28fe34a5fed89dc568d13511c1 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 17:40:24 +0200 Subject: [PATCH 48/99] pdf: fix for web-based PDF generation: images were not available b/c of optimized wrapping -- undoing that for special case of Scene that uses rasterx renderer on web. --- base/iox/imagex/wrapjs_js.go | 6 +++++ base/iox/imagex/wrapjs_notjs.go | 6 +++++ content/examples/basic/content/func-button.md | 11 +++++++++ content/handlers.go | 2 +- core/image.go | 21 ++++++++++++++++- htmlcore/context.go | 6 ++--- styles/object.go | 23 +++++++++++++++++++ 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/base/iox/imagex/wrapjs_js.go b/base/iox/imagex/wrapjs_js.go index 3b4b2c93a4..97a5107f9d 100644 --- a/base/iox/imagex/wrapjs_js.go +++ b/base/iox/imagex/wrapjs_js.go @@ -130,6 +130,12 @@ func (im *JSRGBA) Update() { im.JS.SetRGBA(im.RGBA) } +// note: per https://github.com/whatwg/html/issues/4785 there is no way +// to actually get the data back from the ImageBitmap :( + +// Underlying returns the underlying image data as a local image.RGBA. +// Note that this may not be possible and a nil will be returned, because +// there is no way to get the image data back from an ImageBitmap at this point. func (im *JSRGBA) Underlying() image.Image { return im.RGBA } diff --git a/base/iox/imagex/wrapjs_notjs.go b/base/iox/imagex/wrapjs_notjs.go index ccaff60e33..39e8836dd3 100644 --- a/base/iox/imagex/wrapjs_notjs.go +++ b/base/iox/imagex/wrapjs_notjs.go @@ -13,6 +13,12 @@ import ( "github.com/anthonynsimon/bild/transform" ) +// JSRGBA is a dummy version of this type so code does not have to be +// conditionalized. +type JSRGBA struct { + *image.RGBA +} + // WrapJS returns a JavaScript optimized wrapper around the given // [image.Image] on web, and just returns the image on other platforms. func WrapJS(src image.Image) image.Image { diff --git a/content/examples/basic/content/func-button.md b/content/examples/basic/content/func-button.md index 1608e9533c..559e2f8333 100644 --- a/content/examples/basic/content/func-button.md +++ b/content/examples/basic/content/func-button.md @@ -3,3 +3,14 @@ Categories = ["Widgets"] +++ A **func button** is a [[button]] that is [[value binding|bound]] to a function. + +{id="figure_one" style="height=10em"} +![This is the first figure. It should be clear **exactly** what is going on here.](media/fig_one.png) + +As you can see in [[#figure_one]], the data turned out just as expected. However, you can never be too careful, because along comes [[#figure_two]], and now look. + +{id="figure_two" style="height=5em"} +![This is the second figure. _Ooops_, I guess it is back to the drawing board.](media/fig_two.png) + +That just goes to show: science is always interesting. + diff --git a/content/handlers.go b/content/handlers.go index 45ea7c6f9d..0d83d010cc 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -245,7 +245,7 @@ func (ct *Content) widgetHandlerFigure(w core.Widget, id string) { } altf := htmlcore.MDToHTML(ct.Context, []byte(alt)) lbl := ct.currentPage.SpecialLabel(id) - lbf := "" + lbl + ": " + string(altf) + "

" + lbf := "" + lbl + ": " + string(altf) + "

" ct.moveToBlockFrame(w, id, lbf, false) } diff --git a/core/image.go b/core/image.go index df20a3b6f0..e02b1a901f 100644 --- a/core/image.go +++ b/core/image.go @@ -11,8 +11,10 @@ import ( "cogentcore.org/core/base/iox/imagex" "cogentcore.org/core/icons" "cogentcore.org/core/math32" + "cogentcore.org/core/paint/renderers/rasterx" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" + "cogentcore.org/core/system" "cogentcore.org/core/tree" "golang.org/x/image/draw" ) @@ -99,6 +101,19 @@ func (im *Image) Render() { } sp := im.Geom.ScrollOffset() + useLocalResize := false + if TheApp.Platform() == system.Web && im.Scene != nil { + // we are using an image renderer, e.g. PDF, offline rendering. + // note that there is no way to get transformed image back from javascript :( + // which would be the cleaner solution here. + if _, ok := im.Scene.Renderer.(*rasterx.Renderer); ok { + if _, ok := im.prevRenderImage.(*imagex.JSRGBA); ok { + im.prevImage = nil // clear + } + useLocalResize = true + } + } + var rimg image.Image if im.prevImage == im.Image && im.Styles.ObjectFit == im.prevObjectFit && im.Geom.Size.Actual.Content == im.prevSize { rimg = im.prevRenderImage @@ -106,7 +121,11 @@ func (im *Image) Render() { im.prevImage = im.Image im.prevObjectFit = im.Styles.ObjectFit im.prevSize = im.Geom.Size.Actual.Content - rimg = im.Styles.ResizeImage(im.Image, im.Geom.Size.Actual.Content) + if useLocalResize { + rimg = im.Styles.ResizeImageLocal(im.Image, im.Geom.Size.Actual.Content) + } else { + rimg = im.Styles.ResizeImage(im.Image, im.Geom.Size.Actual.Content) + } im.prevRenderImage = rimg } pim := im.Scene.Painter.DrawImage(rimg, r, sp, draw.Over) diff --git a/htmlcore/context.go b/htmlcore/context.go index f82a7587e1..647a7e859d 100644 --- a/htmlcore/context.go +++ b/htmlcore/context.go @@ -163,9 +163,9 @@ func (c *Context) config(w core.Widget) { func (c *Context) setStyleAttr(node *html.Node, style string) error { // our CSS parser is strict about semicolons, but // they aren't needed in normal inline styles in HTML - if !strings.HasSuffix(style, ";") { - style += ";" - } + // if !strings.HasSuffix(style, ";") { + // style += ";" + // } decls, err := parser.ParseDeclarations(style) if errors.Log(err) != nil { return err diff --git a/styles/object.go b/styles/object.go index 083bb579a3..c8fc3d7ae6 100644 --- a/styles/object.go +++ b/styles/object.go @@ -6,9 +6,11 @@ package styles import ( "image" + "image/draw" "cogentcore.org/core/base/iox/imagex" "cogentcore.org/core/math32" + "github.com/anthonynsimon/bild/transform" ) // ObjectFits are the different ways in which a replaced element @@ -96,3 +98,24 @@ func (s *Style) ResizeImage(img image.Image, box math32.Vector2) image.Image { drect := image.Rect(0, 0, int(min(sz.X, box.X)), int(min(sz.Y, box.Y))) return imagex.Crop(rimg, drect) } + +// ResizeImageLocal resizes the given image according to [Style.ObjectFit] +// in an object of the given box size. This version uses local Go image +// resizing regardless of what platform, for use with local Image rendering. +func (s *Style) ResizeImageLocal(img image.Image, box math32.Vector2) image.Image { + obj := math32.FromPoint(img.Bounds().Size()) + sz := ObjectSizeFromFit(s.ObjectFit, obj, box) + if s.ObjectFit == FitScaleDown && sz.X >= obj.X { + return img + } + szi := sz.ToPointFloor() + rimg := transform.Resize(img, szi.X, szi.Y, transform.Linear) + if s.ObjectFit != FitCover || (box.X >= sz.X && box.Y >= sz.Y) { + return rimg + } + // need to crop the destination size to the size of the containing object + drect := image.Rect(0, 0, int(min(sz.X, box.X)), int(min(sz.Y, box.Y))) + dst := image.NewRGBA(drect) + draw.Draw(dst, drect, rimg, image.Point{}, draw.Src) + return dst +} From 92d841479c19701a27a4a678005295ffb3360ac3 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 9 Oct 2025 18:29:11 +0200 Subject: [PATCH 49/99] pdf: yeah that semicolon thing is real. what wasn't real was my bad style syntax in test. --- content/examples/basic/content/func-button.md | 4 ++-- htmlcore/context.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/content/examples/basic/content/func-button.md b/content/examples/basic/content/func-button.md index 559e2f8333..3ce04aab9c 100644 --- a/content/examples/basic/content/func-button.md +++ b/content/examples/basic/content/func-button.md @@ -4,12 +4,12 @@ Categories = ["Widgets"] A **func button** is a [[button]] that is [[value binding|bound]] to a function. -{id="figure_one" style="height=10em"} +{id="figure_one" style="height:10em"} ![This is the first figure. It should be clear **exactly** what is going on here.](media/fig_one.png) As you can see in [[#figure_one]], the data turned out just as expected. However, you can never be too careful, because along comes [[#figure_two]], and now look. -{id="figure_two" style="height=5em"} +{id="figure_two" style="height:5em"} ![This is the second figure. _Ooops_, I guess it is back to the drawing board.](media/fig_two.png) That just goes to show: science is always interesting. diff --git a/htmlcore/context.go b/htmlcore/context.go index 647a7e859d..f82a7587e1 100644 --- a/htmlcore/context.go +++ b/htmlcore/context.go @@ -163,9 +163,9 @@ func (c *Context) config(w core.Widget) { func (c *Context) setStyleAttr(node *html.Node, style string) error { // our CSS parser is strict about semicolons, but // they aren't needed in normal inline styles in HTML - // if !strings.HasSuffix(style, ";") { - // style += ";" - // } + if !strings.HasSuffix(style, ";") { + style += ";" + } decls, err := parser.ParseDeclarations(style) if errors.Log(err) != nil { return err From 17c7fa4ca4558c6ce110053c738d202185933142 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 10 Oct 2025 06:42:49 +0200 Subject: [PATCH 50/99] pdf: parse/golang: prevent recursive type loop from crashing --- content/content.go | 2 ++ text/parse/languages/golang/types.go | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/content/content.go b/content/content.go index 1afe30dd26..7dc1dce54e 100644 --- a/content/content.go +++ b/content/content.go @@ -484,6 +484,8 @@ func (ct *Content) PagePDF(path string) error { if ct.currentPage == nil { return errors.Log(errors.New("Page empty")) } + core.MessageSnackbar(ct, "Generating PDF...") + ct.inPDFRender = true ct.reloadPage() ct.inPDFRender = false diff --git a/text/parse/languages/golang/types.go b/text/parse/languages/golang/types.go index dcfca1ea12..ca3864336c 100644 --- a/text/parse/languages/golang/types.go +++ b/text/parse/languages/golang/types.go @@ -291,8 +291,19 @@ func (gl *GoLang) TypeFromASTPrim(fs *parse.FileState, pkg *syms.Symbol, ty *sym return nil, false } +var typeDepth int + // TypeFromASTComp handles composite type processing func (gl *GoLang) TypeFromASTComp(fs *parse.FileState, pkg *syms.Symbol, ty *syms.Type, tyast *parser.AST) (*syms.Type, bool) { + typeDepth++ + if typeDepth > 100 { + typeDepth = 0 + return nil, false + } + defer func() { + typeDepth-- + }() + tnm := gl.ASTTypeName(tyast) newTy := false if ty == nil { From dea8cd12140b4c0a17766512cd5914d56dbe8aab Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 10 Oct 2025 07:54:56 +0200 Subject: [PATCH 51/99] pdf: printer.Settings handles all the standard printer settings, plugs into global settings system where needed (call core.AddPrinterSettings()) --- content/settings.go | 2 + core/settings.go | 12 +++ go.mod | 7 +- go.sum | 4 +- system/app.go | 4 + system/driver/base/app.go | 5 ++ system/locale.go | 31 +++++++ text/paginate/options.go | 32 +------- text/paginate/pagesizes/enumgen.go | 46 ----------- text/paginate/paginate.go | 1 - text/paginate/pdf.go | 1 - text/printer/enumgen.go | 50 ++++++++++++ .../pagesizes => printer}/pagesizes.go | 29 ++++--- text/printer/settings.go | 81 +++++++++++++++++++ text/printer/typegen.go | 13 +++ 15 files changed, 225 insertions(+), 93 deletions(-) create mode 100644 system/locale.go delete mode 100644 text/paginate/pagesizes/enumgen.go create mode 100644 text/printer/enumgen.go rename text/{paginate/pagesizes => printer}/pagesizes.go (78%) create mode 100644 text/printer/settings.go create mode 100644 text/printer/typegen.go diff --git a/content/settings.go b/content/settings.go index d2e8183381..a6c9a47c5a 100644 --- a/content/settings.go +++ b/content/settings.go @@ -8,11 +8,13 @@ import ( "time" "cogentcore.org/core/content/bcontent" + "cogentcore.org/core/core" "cogentcore.org/core/text/paginate" "cogentcore.org/core/text/rich" ) func init() { + core.AddPrinterSettings() Settings.Defaults() } diff --git a/core/settings.go b/core/settings.go index 34adf26649..9e427a2ede 100644 --- a/core/settings.go +++ b/core/settings.go @@ -11,6 +11,7 @@ import ( "os/user" "path/filepath" "reflect" + "slices" "time" "cogentcore.org/core/base/errors" @@ -27,6 +28,7 @@ import ( "cogentcore.org/core/icons" "cogentcore.org/core/keymap" "cogentcore.org/core/system" + "cogentcore.org/core/text/printer" "cogentcore.org/core/text/rich" "cogentcore.org/core/text/text" "cogentcore.org/core/tree" @@ -38,6 +40,16 @@ import ( // app settings. var AllSettings = []Settings{AppearanceSettings, SystemSettings, TimingSettings, DebugSettings} +// AddAppSettings inserts app-specific settings after the AppearanceSettings +func AddAppSettings(sets ...Settings) { + AllSettings = slices.Insert(AllSettings, 1, sets...) +} + +// AddPrinterSettings adds printer settings, for apps that use these. +func AddPrinterSettings() { + AddAppSettings(&printer.Settings) +} + // Settings is the interface that describes the functionality common // to all settings data types. type Settings interface { diff --git a/go.mod b/go.mod index 335965af9b..4de227f8ee 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module cogentcore.org/core go 1.23.4 require ( - codeberg.org/go-pdf/fpdf v0.11.1 github.com/Bios-Marcel/wastebasket/v2 v2.0.3 github.com/Masterminds/semver/v3 v3.2.1 github.com/Masterminds/vcs v1.13.3 github.com/adrg/strutil v0.3.1 + github.com/alecthomas/assert/v2 v2.6.0 github.com/alecthomas/chroma/v2 v2.13.0 github.com/anthonynsimon/bild v0.13.0 github.com/aymerick/douceur v0.2.0 @@ -29,6 +29,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/hack-pad/hackpadfs v0.2.1 github.com/jackmordaunt/icns/v2 v2.2.7 + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade github.com/jinzhu/copier v0.4.0 github.com/mattn/go-shellwords v1.0.12 github.com/mitchellh/go-homedir v1.1.0 @@ -42,12 +43,14 @@ require ( golang.org/x/net v0.38.0 golang.org/x/oauth2 v0.27.0 golang.org/x/sys v0.31.0 + golang.org/x/text v0.23.0 golang.org/x/tools v0.29.0 gopkg.in/yaml.v3 v3.0.1 star-tex.org/x/tex v0.6.0 ) require ( + github.com/alecthomas/repr v0.4.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.0 // indirect @@ -57,6 +60,7 @@ require ( github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.1 // indirect github.com/hajimehoshi/oto v0.7.1 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -69,7 +73,6 @@ require ( golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/sync v0.12.0 // indirect - golang.org/x/text v0.23.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect modernc.org/knuth v0.5.4 // indirect modernc.org/token v1.1.0 // indirect diff --git a/go.sum b/go.sum index ecfd6fdc63..4d1a3ed256 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs= -codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= github.com/Bios-Marcel/wastebasket/v2 v2.0.3 h1:TkoDPcSqluhLGE+EssHu7UGmLgUEkWg7kNyHyyJ3Q9g= github.com/Bios-Marcel/wastebasket/v2 v2.0.3/go.mod h1:769oPCv6eH7ugl90DYIsWwjZh4hgNmMS3Zuhe1bH6KU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -102,6 +100,8 @@ github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBD github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jackmordaunt/icns/v2 v2.2.7 h1:K/RbfvuzjmjVY5y4g+XENRs8ZZatwz4YnLHypa2KwQg= github.com/jackmordaunt/icns/v2 v2.2.7/go.mod h1:ovoTxGguSuoUGKMk5Nn3R7L7BgMQkylsO+bblBuI22A= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= diff --git a/system/app.go b/system/app.go index 2a3754ccfb..f5c0eccceb 100644 --- a/system/app.go +++ b/system/app.go @@ -61,6 +61,10 @@ type App interface { // returns the platform of the underlying operating system. SystemPlatform() Platforms + // SystemLocale returns the https://www.rfc-editor.org/rfc/bcp/bcp47.txt standard + // language tag, consisting of language and region, e.g., "en-US", "fr-FR", "ja-JP". + SystemLocale() Locale + // SystemInfo returns any additional information about the underlying system // that is not otherwise given. It is used in crash logs. SystemInfo() string diff --git a/system/driver/base/app.go b/system/driver/base/app.go index 11969dcdbf..f3248d59fa 100644 --- a/system/driver/base/app.go +++ b/system/driver/base/app.go @@ -20,6 +20,7 @@ import ( "cogentcore.org/core/events/key" "cogentcore.org/core/styles" "cogentcore.org/core/system" + "github.com/jeandeaual/go-locale" ) // App contains the data and logic common to all implementations of [system.App]. @@ -119,6 +120,10 @@ func (a *App) SystemInfo() string { return "" // no-op by default } +func (a *App) SystemLocale() system.Locale { + return system.Locale(errors.Log1(locale.GetLocale())) +} + func (a *App) Name() string { return a.Nm } diff --git a/system/locale.go b/system/locale.go new file mode 100644 index 0000000000..e8f47afc70 --- /dev/null +++ b/system/locale.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package system + +import "strings" + +// Locale represents a https://www.rfc-editor.org/rfc/bcp/bcp47.txt standard +// language tag, consisting of language and region, e.g., "en-US", "fr-FR", "ja-JP". +type Locale string + +// Language returns the language portion of the locale tag (e.g., en, fr, ja) +func (l Locale) Language() string { + if l == "" { + return "" + } + return strings.Split(string(l), "-")[0] +} + +// Region returns the region portion of the locale tag (e.g., US, FR, JA) +func (l Locale) Region() string { + if l == "" { + return "" + } + pos := strings.LastIndex(string(l), "-") + if pos < 0 { + return "" + } + return string(l)[:pos] +} diff --git a/text/paginate/options.go b/text/paginate/options.go index e86e7ef2b7..b7de958483 100644 --- a/text/paginate/options.go +++ b/text/paginate/options.go @@ -11,26 +11,12 @@ import ( "cogentcore.org/core/math32" "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" - "cogentcore.org/core/text/paginate/pagesizes" + "cogentcore.org/core/text/printer" "cogentcore.org/core/text/rich" ) // Options has the parameters for pagination. type Options struct { - // PageSize specifies a standard page size, or Custom. - PageSize pagesizes.Sizes - - // Units are the units in which size is specified. - // Will automatically be set if PageSize != Custom. - Units units.Units - - // Size is the size in given units. - // Will automatically be set if PageSize != Custom. - Size math32.Vector2 - - // Margins specify the page margins in the size units. - Margins sides.Floats `display:"inline"` - // FontFamily specifies the default font family to apply // to all core.Text elements. FontFamily rich.Family @@ -71,23 +57,9 @@ func NewOptions() Options { } func (o *Options) Defaults() { - // todo: make this contingent on localization somehow! - o.PageSize = pagesizes.A4 - o.Margins.Set(25) // basically one inch o.Footer = CenteredPageNumber - o.Update() -} - -func (o *Options) Update() { - if o.PageSize != pagesizes.Custom { - o.Units, o.Size = o.PageSize.Size() - } } func (o *Options) ToDots(un *units.Context) { - sc := un.ToDots(1, o.Units) - o.SizeDots = o.Size.MulScalar(sc) - o.MargDots = o.Margins.MulScalar(sc) - o.BodyDots.X = o.SizeDots.X - (o.MargDots.Left + o.MargDots.Right) - o.BodyDots.Y = o.SizeDots.Y - (o.MargDots.Top + o.MargDots.Bottom) + o.SizeDots, o.BodyDots, o.MargDots = printer.Settings.ToDots(un) } diff --git a/text/paginate/pagesizes/enumgen.go b/text/paginate/pagesizes/enumgen.go deleted file mode 100644 index 0d3a4b3edb..0000000000 --- a/text/paginate/pagesizes/enumgen.go +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by "core generate"; DO NOT EDIT. - -package pagesizes - -import ( - "cogentcore.org/core/enums" -) - -var _SizesValues = []Sizes{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21} - -// SizesN is the highest valid value for type Sizes, plus one. -const SizesN Sizes = 22 - -var _SizesValueMap = map[string]Sizes{`Custom`: 0, `Img1280x720`: 1, `Img1920x1080`: 2, `Img3840x2160`: 3, `Img7680x4320`: 4, `Img1024x768`: 5, `Img720x480`: 6, `Img640x480`: 7, `Img320x240`: 8, `A4`: 9, `USLetter`: 10, `USLegal`: 11, `A0`: 12, `A1`: 13, `A2`: 14, `A3`: 15, `A5`: 16, `A6`: 17, `A7`: 18, `A8`: 19, `A9`: 20, `A10`: 21} - -var _SizesDescMap = map[Sizes]string{0: `Custom = nonstandard`, 1: `Image 1280x720 Px = 720p`, 2: `Image 1920x1080 Px = 1080p HD`, 3: `Image 3840x2160 Px = 4K`, 4: `Image 7680x4320 Px = 8K`, 5: `Image 1024x768 Px = XGA`, 6: `Image 720x480 Px = DVD`, 7: `Image 640x480 Px = VGA`, 8: `Image 320x240 Px = old CRT`, 9: `A4 = 210 x 297 mm`, 10: `USLetter = 8.5 x 11 in = 612 x 792 pt`, 11: `USLegal = 8.5 x 14 in = 612 x 1008 pt`, 12: `A0 = 841 x 1189 mm`, 13: `A1 = 594 x 841 mm`, 14: `A2 = 420 x 594 mm`, 15: `A3 = 297 x 420 mm`, 16: `A5 = 148 x 210 mm`, 17: `A6 = 105 x 148 mm`, 18: `A7 = 74 x 105`, 19: `A8 = 52 x 74 mm`, 20: `A9 = 37 x 52`, 21: `A10 = 26 x 37`} - -var _SizesMap = map[Sizes]string{0: `Custom`, 1: `Img1280x720`, 2: `Img1920x1080`, 3: `Img3840x2160`, 4: `Img7680x4320`, 5: `Img1024x768`, 6: `Img720x480`, 7: `Img640x480`, 8: `Img320x240`, 9: `A4`, 10: `USLetter`, 11: `USLegal`, 12: `A0`, 13: `A1`, 14: `A2`, 15: `A3`, 16: `A5`, 17: `A6`, 18: `A7`, 19: `A8`, 20: `A9`, 21: `A10`} - -// String returns the string representation of this Sizes value. -func (i Sizes) String() string { return enums.String(i, _SizesMap) } - -// SetString sets the Sizes value from its string representation, -// and returns an error if the string is invalid. -func (i *Sizes) SetString(s string) error { return enums.SetString(i, s, _SizesValueMap, "Sizes") } - -// Int64 returns the Sizes value as an int64. -func (i Sizes) Int64() int64 { return int64(i) } - -// SetInt64 sets the Sizes value from an int64. -func (i *Sizes) SetInt64(in int64) { *i = Sizes(in) } - -// Desc returns the description of the Sizes value. -func (i Sizes) Desc() string { return enums.Desc(i, _SizesDescMap) } - -// SizesValues returns all possible values for the type Sizes. -func SizesValues() []Sizes { return _SizesValues } - -// Values returns all possible values for the type Sizes. -func (i Sizes) Values() []enums.Enum { return enums.Values(_SizesValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i Sizes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *Sizes) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Sizes") } diff --git a/text/paginate/paginate.go b/text/paginate/paginate.go index 8e5181aac3..cd5ae79e70 100644 --- a/text/paginate/paginate.go +++ b/text/paginate/paginate.go @@ -39,7 +39,6 @@ type pager struct { // optsUpdate updates the option sizes based on unit context in first input. func (p *pager) optsUpdate() { - p.opts.Update() in0 := p.ins[0].AsWidget() p.ctx = in0.Styles.UnitContext p.opts.ToDots(&p.ctx) diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index eb53c012d1..9f0879b3db 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -29,7 +29,6 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { cset := pdf.UseStandardFonts() p := pager{opts: &opts, ins: ins} - p.opts.Update() p.ctx = *units.NewContext() // generic, invariant of actual context p.opts.ToDots(&p.ctx) p.assemble() diff --git a/text/printer/enumgen.go b/text/printer/enumgen.go new file mode 100644 index 0000000000..4d0ad4add2 --- /dev/null +++ b/text/printer/enumgen.go @@ -0,0 +1,50 @@ +// Code generated by "core generate -add-types"; DO NOT EDIT. + +package printer + +import ( + "cogentcore.org/core/enums" +) + +var _PageSizesValues = []PageSizes{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21} + +// PageSizesN is the highest valid value for type PageSizes, plus one. +const PageSizesN PageSizes = 22 + +var _PageSizesValueMap = map[string]PageSizes{`Custom`: 0, `Img1280x720`: 1, `Img1920x1080`: 2, `Img3840x2160`: 3, `Img7680x4320`: 4, `Img1024x768`: 5, `Img720x480`: 6, `Img640x480`: 7, `Img320x240`: 8, `A4`: 9, `USLetter`: 10, `USLegal`: 11, `A0`: 12, `A1`: 13, `A2`: 14, `A3`: 15, `A5`: 16, `A6`: 17, `A7`: 18, `A8`: 19, `A9`: 20, `A10`: 21} + +var _PageSizesDescMap = map[PageSizes]string{0: `Custom = nonstandard`, 1: `Image 1280x720 Px = 720p`, 2: `Image 1920x1080 Px = 1080p HD`, 3: `Image 3840x2160 Px = 4K`, 4: `Image 7680x4320 Px = 8K`, 5: `Image 1024x768 Px = XGA`, 6: `Image 720x480 Px = DVD`, 7: `Image 640x480 Px = VGA`, 8: `Image 320x240 Px = old CRT`, 9: `A4 = 210 x 297 mm`, 10: `USLetter = 8.5 x 11 in = 612 x 792 pt`, 11: `USLegal = 8.5 x 14 in = 612 x 1008 pt`, 12: `A0 = 841 x 1189 mm`, 13: `A1 = 594 x 841 mm`, 14: `A2 = 420 x 594 mm`, 15: `A3 = 297 x 420 mm`, 16: `A5 = 148 x 210 mm`, 17: `A6 = 105 x 148 mm`, 18: `A7 = 74 x 105`, 19: `A8 = 52 x 74 mm`, 20: `A9 = 37 x 52`, 21: `A10 = 26 x 37`} + +var _PageSizesMap = map[PageSizes]string{0: `Custom`, 1: `Img1280x720`, 2: `Img1920x1080`, 3: `Img3840x2160`, 4: `Img7680x4320`, 5: `Img1024x768`, 6: `Img720x480`, 7: `Img640x480`, 8: `Img320x240`, 9: `A4`, 10: `USLetter`, 11: `USLegal`, 12: `A0`, 13: `A1`, 14: `A2`, 15: `A3`, 16: `A5`, 17: `A6`, 18: `A7`, 19: `A8`, 20: `A9`, 21: `A10`} + +// String returns the string representation of this PageSizes value. +func (i PageSizes) String() string { return enums.String(i, _PageSizesMap) } + +// SetString sets the PageSizes value from its string representation, +// and returns an error if the string is invalid. +func (i *PageSizes) SetString(s string) error { + return enums.SetString(i, s, _PageSizesValueMap, "PageSizes") +} + +// Int64 returns the PageSizes value as an int64. +func (i PageSizes) Int64() int64 { return int64(i) } + +// SetInt64 sets the PageSizes value from an int64. +func (i *PageSizes) SetInt64(in int64) { *i = PageSizes(in) } + +// Desc returns the description of the PageSizes value. +func (i PageSizes) Desc() string { return enums.Desc(i, _PageSizesDescMap) } + +// PageSizesValues returns all possible values for the type PageSizes. +func PageSizesValues() []PageSizes { return _PageSizesValues } + +// Values returns all possible values for the type PageSizes. +func (i PageSizes) Values() []enums.Enum { return enums.Values(_PageSizesValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i PageSizes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *PageSizes) UnmarshalText(text []byte) error { + return enums.UnmarshalText(i, text, "PageSizes") +} diff --git a/text/paginate/pagesizes/pagesizes.go b/text/printer/pagesizes.go similarity index 78% rename from text/paginate/pagesizes/pagesizes.go rename to text/printer/pagesizes.go index d8a3ab77c1..abd5f185c2 100644 --- a/text/paginate/pagesizes/pagesizes.go +++ b/text/printer/pagesizes.go @@ -2,12 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:generate core generate - -// package pagesizes provides an enum of standard page sizes -// including image (e.g., 1080p, 4K, etc) and printed page sizes -// (e.g., A4, USLetter). -package pagesizes +package printer import ( "cogentcore.org/core/math32" @@ -15,11 +10,11 @@ import ( ) // Sizes are standard physical drawing sizes -type Sizes int32 //enums:enum +type PageSizes int32 //enums:enum const ( // Custom = nonstandard - Custom Sizes = iota + Custom PageSizes = iota // Image 1280x720 Px = 720p Img1280x720 @@ -86,13 +81,13 @@ const ( ) // Size returns the corresponding size values and units. -func (s Sizes) Size() (un units.Units, size math32.Vector2) { +func (s PageSizes) Size() (un units.Units, size math32.Vector2) { v := sizesMap[s] return v.un, math32.Vec2(v.x, v.y) } // Match returns a matching standard size for given units and dimension. -func Match(un units.Units, wd, ht float32) Sizes { +func Match(un units.Units, wd, ht float32) PageSizes { trgl := values{un: un, x: wd, y: ht} trgp := values{un: un, x: ht, y: wd} for k, v := range sizesMap { @@ -111,7 +106,7 @@ type values struct { } // sizesMap is the map of size values for each standard size -var sizesMap = map[Sizes]*values{ +var sizesMap = map[PageSizes]*values{ Img1280x720: {units.UnitPx, 1280, 720}, Img1920x1080: {units.UnitPx, 1920, 1080}, Img3840x2160: {units.UnitPx, 3840, 2160}, @@ -134,3 +129,15 @@ var sizesMap = map[Sizes]*values{ A9: {units.UnitMm, 37, 52}, A10: {units.UnitMm, 26, 37}, } + +// DefaultPageSizeForRegion returns the default page size based on locale +// region value, per the https://www.rfc-editor.org/rfc/bcp/bcp47.txt standard. +// See [system.Locale] and [system.App] for how to get this. +func DefaultPageSizeForRegion(region string) PageSizes { + switch region { + case "US", "CA", "BZ", "CL", "CR", "GT", "NI", "PA", "PR", "SV", "VE", "MX", "CO", "PH": + return USLetter + default: + return A4 + } +} diff --git a/text/printer/settings.go b/text/printer/settings.go new file mode 100644 index 0000000000..a4b2dea5cc --- /dev/null +++ b/text/printer/settings.go @@ -0,0 +1,81 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:generate core generate -add-types + +// package printer provides standard printer settings. +package printer + +import ( + "path/filepath" + + "cogentcore.org/core/math32" + "cogentcore.org/core/styles/sides" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/system" + "cogentcore.org/core/tree" +) + +func init() { + Settings.Defaults() +} + +// Settings provides the default printer settings. +var Settings SettingsData + +// Settings has standard printer settings. +type SettingsData struct { + // PageSize specifies a standard page size, or Custom. + PageSize PageSizes + + // Units are the units in which size is specified. + // Will automatically be set if PageSize != Custom. + Units units.Units + + // Size is the size in given units. + // Will automatically be set if PageSize != Custom. + Size math32.Vector2 + + // Margins specify the page margins in the size units. + Margins sides.Floats `display:"inline"` +} + +func (ps *SettingsData) Defaults() { + ps.PageSize = DefaultPageSizeForRegion(system.TheApp.SystemLocale().Region()) + ps.Margins.Set(25) // basically one inch + ps.Update() +} + +func (ps *SettingsData) Update() { + if ps.PageSize != Custom { + ps.Units, ps.Size = ps.PageSize.Size() + } +} + +func (ps *SettingsData) Apply() { + ps.Update() +} + +func (ps *SettingsData) Label() string { + return "Printer" +} + +func (ps *SettingsData) Filename() string { + return filepath.Join(system.TheApp.CogentCoreDataDir(), "printer-settings.toml") +} + +func (ps *SettingsData) MakeToolbar(p *tree.Plan) { +} + +// ToDots returns the measurement values in rendering dots (actual pixels) +// based on the given units context. +// size = page size; body = content area inside margins +func (ps *SettingsData) ToDots(un *units.Context) (size, body math32.Vector2, margins sides.Floats) { + sc := un.ToDots(1, ps.Units) + size = ps.Size.MulScalar(sc) + margins = ps.Margins.MulScalar(sc) + body.X = size.X - (margins.Left + margins.Right) + body.Y = size.Y - (margins.Top + margins.Bottom) + return +} diff --git a/text/printer/typegen.go b/text/printer/typegen.go new file mode 100644 index 0000000000..2c56e39cdc --- /dev/null +++ b/text/printer/typegen.go @@ -0,0 +1,13 @@ +// Code generated by "core generate -add-types"; DO NOT EDIT. + +package printer + +import ( + "cogentcore.org/core/types" +) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/printer.PageSizes", IDName: "page-sizes", Doc: "Sizes are standard physical drawing sizes"}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/printer.values", IDName: "values", Doc: "values are values for standard sizes", Fields: []types.Field{{Name: "un"}, {Name: "x"}, {Name: "y"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/printer.SettingsData", IDName: "settings-data", Doc: "Settings has standard printer settings.", Fields: []types.Field{{Name: "PageSize", Doc: "PageSize specifies a standard page size, or Custom."}, {Name: "Units", Doc: "Units are the units in which size is specified.\nWill automatically be set if PageSize != Custom."}, {Name: "Size", Doc: "Size is the size in given units.\nWill automatically be set if PageSize != Custom."}, {Name: "Margins", Doc: "Margins specify the page margins in the size units."}}}) From 7c550bb0bc971c8afd3681a9f0bfe114f922ec29 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 10 Oct 2025 10:41:44 +0200 Subject: [PATCH 52/99] pdf: misc settings fixes -- all good --- system/driver/base/app.go | 4 ++++ system/locale.go | 8 ++++++-- text/printer/settings.go | 27 +++++++++++++++++++-------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/system/driver/base/app.go b/system/driver/base/app.go index f3248d59fa..10c3e34f2f 100644 --- a/system/driver/base/app.go +++ b/system/driver/base/app.go @@ -20,6 +20,7 @@ import ( "cogentcore.org/core/events/key" "cogentcore.org/core/styles" "cogentcore.org/core/system" + "cogentcore.org/core/text/printer" "github.com/jeandeaual/go-locale" ) @@ -68,6 +69,9 @@ func Init(a system.App, ab *App) { ab.This = a system.TheApp = a key.SystemPlatform = a.SystemPlatform().String() + // sl := a.SystemLocale() + // fmt.Println("locale:", sl, sl.Language(), sl.Region()) + printer.Settings.Defaults() // depends on system.TheApp } func (a *App) MainLoop() { diff --git a/system/locale.go b/system/locale.go index e8f47afc70..0ae52e8191 100644 --- a/system/locale.go +++ b/system/locale.go @@ -15,7 +15,11 @@ func (l Locale) Language() string { if l == "" { return "" } - return strings.Split(string(l), "-")[0] + pos := strings.LastIndex(string(l), "-") + if pos < 0 { + return string(l) + } + return string(l)[:pos] } // Region returns the region portion of the locale tag (e.g., US, FR, JA) @@ -27,5 +31,5 @@ func (l Locale) Region() string { if pos < 0 { return "" } - return string(l)[:pos] + return string(l)[pos+1:] } diff --git a/text/printer/settings.go b/text/printer/settings.go index a4b2dea5cc..5ed4714151 100644 --- a/text/printer/settings.go +++ b/text/printer/settings.go @@ -17,11 +17,9 @@ import ( "cogentcore.org/core/tree" ) -func init() { - Settings.Defaults() -} - // Settings provides the default printer settings. +// This is initialized by the system.App because it depends on +// the locale for initialization. var Settings SettingsData // Settings has standard printer settings. @@ -29,11 +27,11 @@ type SettingsData struct { // PageSize specifies a standard page size, or Custom. PageSize PageSizes - // Units are the units in which size is specified. + // Units are the units in which the page size is specified. // Will automatically be set if PageSize != Custom. Units units.Units - // Size is the size in given units. + // Size is the page size in given units. // Will automatically be set if PageSize != Custom. Size math32.Vector2 @@ -43,13 +41,26 @@ type SettingsData struct { func (ps *SettingsData) Defaults() { ps.PageSize = DefaultPageSizeForRegion(system.TheApp.SystemLocale().Region()) - ps.Margins.Set(25) // basically one inch + switch ps.Units { + case units.UnitMm: + ps.Margins.Set(25) // basically one inch + case units.UnitPt: + ps.Margins.Set(72) + case units.UnitPx: + ps.Margins.Set(24) + } ps.Update() } func (ps *SettingsData) Update() { if ps.PageSize != Custom { + pU := ps.Units ps.Units, ps.Size = ps.PageSize.Size() + if pU != ps.Units { + uc := units.NewContext() + sc := uc.Convert(1, pU, ps.Units) + ps.Margins = ps.Margins.MulScalar(sc) + } } } @@ -73,7 +84,7 @@ func (ps *SettingsData) MakeToolbar(p *tree.Plan) { // size = page size; body = content area inside margins func (ps *SettingsData) ToDots(un *units.Context) (size, body math32.Vector2, margins sides.Floats) { sc := un.ToDots(1, ps.Units) - size = ps.Size.MulScalar(sc) + size = ps.Size.MulScalar(sc).Floor() margins = ps.Margins.MulScalar(sc) body.X = size.X - (margins.Left + margins.Right) body.Y = size.Y - (margins.Top + margins.Bottom) From 6f93ef2b284caaac41fc30ce9d60caca04f1177f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 12 Oct 2025 09:03:50 +0200 Subject: [PATCH 53/99] pdf: more printer formatting settings and enforcement of actual font size, line spacing etc to accomplish an exact target font size. --- content/content.go | 2 + content/settings.go | 2 - paint/pdf/page.go | 411 -------------------------------------- paint/pdf/paint.go | 295 +++++++++++++++++++++++++++ paint/pdf/pdf_test.go | 35 ++++ paint/pdf/text.go | 147 ++++++++++++++ paint/pdf/writer.go | 2 + svg/testdata/svg/test.svg | 2 +- text/paginate/options.go | 12 +- text/paginate/pdf.go | 23 ++- text/paginate/runners.go | 41 ++-- text/printer/settings.go | 27 +++ 12 files changed, 552 insertions(+), 447 deletions(-) create mode 100644 paint/pdf/paint.go diff --git a/content/content.go b/content/content.go index 7dc1dce54e..4647f7e288 100644 --- a/content/content.go +++ b/content/content.go @@ -486,6 +486,8 @@ func (ct *Content) PagePDF(path string) error { } core.MessageSnackbar(ct, "Generating PDF...") + Settings.PDF.FontScale = (100.0 / core.AppearanceSettings.DocsFontSize) + ct.inPDFRender = true ct.reloadPage() ct.inPDFRender = false diff --git a/content/settings.go b/content/settings.go index a6c9a47c5a..5411eee148 100644 --- a/content/settings.go +++ b/content/settings.go @@ -10,7 +10,6 @@ import ( "cogentcore.org/core/content/bcontent" "cogentcore.org/core/core" "cogentcore.org/core/text/paginate" - "cogentcore.org/core/text/rich" ) func init() { @@ -38,7 +37,6 @@ type SettingsData struct { func (s *SettingsData) Defaults() { s.PDF.Defaults() - s.PDF.FontFamily = rich.Serif s.PDF.Footer = nil s.PageSettings = func(ct *Content, curPage *bcontent.Page) *SettingsData { diff --git a/paint/pdf/page.go b/paint/pdf/page.go index 66d12fba33..6271bf9571 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -11,19 +11,9 @@ import ( "bytes" "fmt" "image" - "image/color" - "log/slog" - "cogentcore.org/core/base/errors" "cogentcore.org/core/base/stack" - "cogentcore.org/core/colors" - "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" - "cogentcore.org/core/paint/ppath" - "cogentcore.org/core/styles" - "cogentcore.org/core/text/rich" - "cogentcore.org/core/text/text" - "golang.org/x/text/encoding/charmap" ) type pdfPage struct { @@ -74,304 +64,6 @@ func (w *pdfPage) writePage(parent pdfRef) pdfRef { return w.pdf.writeObject(page) } -// SetFill sets the fill style values where different from current. -func (w *pdfPage) SetFill(fill *styles.Fill) { - csty := w.style() - if csty.Fill.Color != fill.Color || csty.Fill.Opacity != fill.Opacity { - w.SetFillColor(fill) - } - csty.Fill = *fill -} - -// SetAlpha sets the transparency value. -func (w *pdfPage) SetAlpha(alpha float32) { - gs := w.getOpacityGS(alpha) - fmt.Fprintf(w, " /%v gs", gs) -} - -// SetFillColor sets the filling color (image). -func (w *pdfPage) SetFillColor(fill *styles.Fill) { - switch x := fill.Color.(type) { - // todo: pattern, image - case *gradient.Linear: - case *gradient.Radial: - // TODO: should we unset cs? - // fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(fill.Gradient)) - case *image.Uniform: - var clr color.RGBA - if x != nil { - clr = colors.ApplyOpacity(colors.AsRGBA(x), fill.Opacity) - } - a := float32(clr.A) / 255.0 - if a == 0 { - fmt.Fprintf(w, " 0 g") - } else if clr.R == clr.G && clr.R == clr.B { - fmt.Fprintf(w, " %v g", dec(float32(clr.R)/255.0/a)) - } else { - fmt.Fprintf(w, " %v %v %v rg", dec(float32(clr.R)/255.0/a), dec(float32(clr.G)/255.0/a), dec(float32(clr.B)/255.0/a)) - } - w.SetAlpha(a) - } -} - -// SetStroke sets the stroke style values where different from current. -func (w *pdfPage) SetStroke(stroke *styles.Stroke) { - csty := w.style() - if csty.Stroke.Color != stroke.Color || csty.Stroke.Opacity != stroke.Opacity { - w.SetStrokeColor(stroke) - } - if csty.Stroke.Width.Dots != stroke.Width.Dots { - w.SetStrokeWidth(stroke.Width.Dots) - } - if csty.Stroke.Cap != stroke.Cap { - w.SetStrokeCap(stroke.Cap) - } - if csty.Stroke.Join != stroke.Join || (csty.Stroke.Join == ppath.JoinMiter && csty.Stroke.MiterLimit != stroke.MiterLimit) { - w.SetStrokeJoin(stroke.Join, stroke.MiterLimit) - } - if len(stroke.Dashes) > 0 { // always do - w.SetDashes(stroke.DashOffset, stroke.Dashes) - } else { - if len(csty.Stroke.Dashes) > 0 { - w.SetDashes(0, nil) - } - } - csty.Stroke = *stroke -} - -// SetStrokeColor sets the stroking color (image). -func (w *pdfPage) SetStrokeColor(stroke *styles.Stroke) { - switch x := stroke.Color.(type) { - case *gradient.Linear: - case *gradient.Radial: - // TODO: should we unset cs? - // fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(stroke.Gradient)) - case *image.Uniform: - clr := colors.ApplyOpacity(colors.AsRGBA(x), stroke.Opacity) - a := float32(clr.A) / 255.0 - if clr.R == clr.G && clr.R == clr.B { - fmt.Fprintf(w, " %v G", dec(float32(clr.R)/255.0/a)) - } else { - fmt.Fprintf(w, " %v %v %v RG", dec(float32(clr.R)/255.0/a), dec(float32(clr.G)/255.0/a), dec(float32(clr.B)/255.0/a)) - } - w.SetAlpha(a) - } -} - -// SetStrokeWidth sets the stroke width. -func (w *pdfPage) SetStrokeWidth(lineWidth float32) { - fmt.Fprintf(w, " %v w", dec(lineWidth)) -} - -// SetStrokeCap sets the stroke cap type. -func (w *pdfPage) SetStrokeCap(capper ppath.Caps) { - var lineCap int - switch capper { - case ppath.CapButt: - lineCap = 0 - case ppath.CapRound: - lineCap = 1 - case ppath.CapSquare: - lineCap = 2 - default: - slog.Error("pdfWriter", "StrokeCap not supported", capper) - } - fmt.Fprintf(w, " %d J", lineCap) -} - -// SetStrokeJoin sets the stroke join type. -func (w *pdfPage) SetStrokeJoin(joiner ppath.Joins, miterLimit float32) { - var lineJoin int - switch joiner { - case ppath.JoinBevel: - lineJoin = 2 - case ppath.JoinRound: - lineJoin = 1 - case ppath.JoinMiter: - lineJoin = 0 - default: - slog.Error("pdfWriter", "StrokeJoin not supported", joiner) - } - fmt.Fprintf(w, " %d j", lineJoin) - if lineJoin == 0 { - fmt.Fprintf(w, " %v M", dec(miterLimit)) - } -} - -// SetDashes sets the dash phase and array. -func (w *pdfPage) SetDashes(dashPhase float32, dashArray []float32) { - if len(dashArray)%2 == 1 { - dashArray = append(dashArray, dashArray...) - } - - // PDF can't handle negative dash phases - if dashPhase < 0.0 { - totalLength := float32(0.0) - for _, dash := range dashArray { - totalLength += dash - } - for dashPhase < 0.0 { - dashPhase += totalLength - } - } - - dashes := append(dashArray, dashPhase) - if len(dashes) == 1 { - fmt.Fprintf(w, " [] 0 d") - dashes[0] = 0.0 - } else { - fmt.Fprintf(w, " [%v", dec(dashes[0])) - for _, dash := range dashes[1 : len(dashes)-1] { - fmt.Fprintf(w, " %v", dec(dash)) - } - fmt.Fprintf(w, "] %v d", dec(dashes[len(dashes)-1])) - } -} - -// SetFont sets the font. -func (w *pdfPage) SetFont(sty *rich.Style, tsty *text.Style) error { - if !w.inTextObject { - return errors.Log(errors.New("pdfWriter: must be in text object")) - } - size := tsty.FontHeight(sty) // * w.pdf.globalScale - ref := w.pdf.getFont(sty, tsty) - if _, ok := w.resources["Font"]; !ok { - w.resources["Font"] = pdfDict{} - } else { - for name, fontRef := range w.resources["Font"].(pdfDict) { - if ref == fontRef { - fmt.Fprintf(w, " /%v %v Tf", name, dec(size)) - return nil - } - } - } - - name := pdfName(fmt.Sprintf("F%d", len(w.resources["Font"].(pdfDict)))) - w.resources["Font"].(pdfDict)[name] = ref - fmt.Fprintf(w, " /%v %v Tf", name, dec(size)) - return nil -} - -// SetTextPosition sets the text offset position. -func (w *pdfPage) SetTextPosition(off math32.Vector2) error { - if !w.inTextObject { - return errors.Log(errors.New("pdfWriter: must be in text object")) - } - do := off.Sub(w.textPosition) - // and finally apply an offset from there, in reverse for Y - fmt.Fprintf(w, " %v %v Td", dec(do.X), dec(-do.Y)) - w.textPosition = off - return nil -} - -// SetTextRenderMode sets the text rendering mode. -// 0 = fill text, 1 = stroke text, 2 = fill, then stroke. -// higher numbers support clip path. -func (w *pdfPage) SetTextRenderMode(mode int) error { - if !w.inTextObject { - return errors.Log(errors.New("pdfWriter: must be in text object")) - } - fmt.Fprintf(w, " %d Tr", mode) - w.textRenderMode = mode - return nil -} - -// SetTextCharSpace sets the text character spacing. -func (w *pdfPage) SetTextCharSpace(space float32) error { - if !w.inTextObject { - return errors.Log(errors.New("pdfWriter: must be in text object")) - } - fmt.Fprintf(w, " %v Tc", dec(space)) - w.textCharSpace = space - return nil -} - -// StartTextObject starts a text object, adding to the graphics -// CTM transform matrix as given by the arg, and setting an inverting -// text transform, so text is rendered upright. -func (w *pdfPage) StartTextObject(m math32.Matrix2) error { - if w.inTextObject { - return errors.Log(errors.New("pdfWriter: already in text object")) - } - // set the graphics transform to m first - w.PushTransform(m) - fmt.Fprintf(w, " BT") - // then apply an inversion text matrix - tm := math32.Scale2D(1, -1) - fmt.Fprintf(w, " %s Tm", mat2(tm)) - w.inTextObject = true - w.textPosition = math32.Vector2{} - return nil -} - -// EndTextObject ends a text object. -func (w *pdfPage) EndTextObject() error { - if !w.inTextObject { - return errors.Log(errors.New("pdfWriter: must be in text object")) - } - fmt.Fprintf(w, " ET") - w.PopStack() - w.inTextObject = false - return nil -} - -// WriteText writes text using current text style. -func (w *pdfPage) WriteText(tx string) error { - if !w.inTextObject { - return errors.Log(errors.New("pdfWriter: must be in text object")) - } - if len(tx) == 0 { - return nil - } - - first := true - write := func(s string) { - if first { - fmt.Fprintf(w, "(") - first = false - } else { - fmt.Fprintf(w, " (") - } - rs := []rune(s) - for _, r := range rs { - c, ok := charmap.Windows1252.EncodeRune(r) - if !ok { - if '\u2000' <= r && r <= '\u200A' { - c = ' ' - } - } - switch c { - case '\n': - w.WriteByte('\\') - w.WriteByte('n') - case '\r': - w.WriteByte('\\') - w.WriteByte('r') - case '\t': - w.WriteByte('\\') - w.WriteByte('t') - case '\b': - w.WriteByte('\\') - w.WriteByte('b') - case '\f': - w.WriteByte('\\') - w.WriteByte('f') - case '\\', '(', ')': - w.WriteByte('\\') - w.WriteByte(c) - default: - w.WriteByte(c) - } - } - fmt.Fprintf(w, ")") - } - - fmt.Fprintf(w, "[") - write(tx) - fmt.Fprintf(w, "]TJ") - return nil -} - // DrawImage embeds and draws an image, as a lossless (PNG) func (w *pdfPage) DrawImage(img image.Image, m math32.Matrix2) { size := img.Bounds().Size() @@ -460,106 +152,3 @@ func (w *pdfPage) embedImage(img image.Image) pdfRef { w.pdf.images[img] = ref return ref } - -func (w *pdfPage) getOpacityGS(a float32) pdfName { - if name, ok := w.graphicsStates[a]; ok { - return name - } - name := pdfName(fmt.Sprintf("A%d", len(w.graphicsStates))) - w.graphicsStates[a] = name - - if _, ok := w.resources["ExtGState"]; !ok { - w.resources["ExtGState"] = pdfDict{} - } - w.resources["ExtGState"].(pdfDict)[name] = pdfDict{ - "CA": a, - "ca": a, - } - return name -} - -/* -func (w *pdfPage) getPattern(gradient ppath.Gradient) pdfName { - // TODO: support patterns/gradients with alpha channel - shading := pdfDict{ - "ColorSpace": pdfName("DeviceRGB"), - } - if g, ok := gradient.(*ppath.LinearGradient); ok { - shading["ShadingType"] = 2 - shading["Coords"] = pdfArray{g.Start.X, g.Start.Y, g.End.X, g.End.Y} - shading["Function"] = patternStopsFunction(g.Stops) - shading["Extend"] = pdfArray{true, true} - } else if g, ok := gradient.(*ppath.RadialGradient); ok { - shading["ShadingType"] = 3 - shading["Coords"] = pdfArray{g.C0.X, g.C0.Y, g.R0, g.C1.X, g.C1.Y, g.R1} - shading["Function"] = patternStopsFunction(g.Stops) - shading["Extend"] = pdfArray{true, true} - } - pattern := pdfDict{ - "Type": pdfName("Pattern"), - "PatternType": 2, - "Shading": shading, - } - - if _, ok := w.resources["Pattern"]; !ok { - w.resources["Pattern"] = pdfDict{} - } - for name, pat := range w.resources["Pattern"].(pdfDict) { - if reflect.DeepEqual(pat, pattern) { - return name - } - } - name := pdfName(fmt.Sprintf("P%d", len(w.resources["Pattern"].(pdfDict)))) - w.resources["Pattern"].(pdfDict)[name] = pattern - return name -} - -func patternStopsFunction(stops ppath.Stops) pdfDict { - if len(stops) < 2 { - return pdfDict{} - } - - fs := []pdfDict{} - encode := pdfArray{} - bounds := pdfArray{} - if !ppath.Equal(stops[0].Offset, 0.0) { - fs = append(fs, patternStopFunction(stops[0], stops[0])) - encode = append(encode, 0, 1) - bounds = append(bounds, stops[0].Offset) - } - for i := 0; i < len(stops)-1; i++ { - fs = append(fs, patternStopFunction(stops[i], stops[i+1])) - encode = append(encode, 0, 1) - if i != 0 { - bounds = append(bounds, stops[1].Offset) - } - } - if !ppath.Equal(stops[len(stops)-1].Offset, 1.0) { - fs = append(fs, patternStopFunction(stops[len(stops)-1], stops[len(stops)-1])) - encode = append(encode, 0, 1) - } - if len(fs) == 1 { - return fs[0] - } - return pdfDict{ - "FunctionType": 3, - "Domain": pdfArray{0, 1}, - "Encode": encode, - "Bounds": bounds, - "Functions": fs, - } -} - -func patternStopFunction(s0, s1 ppath.Stop) pdfDict { - a0 := float32(s0.Color.A) / 255.0 - a1 := float32(s1.Color.A) / 255.0 - return pdfDict{ - "FunctionType": 2, - "Domain": pdfArray{0, 1}, - "N": 1, - "C0": pdfArray{float32(s0.Color.R) / 255.0 / a0, float32(s0.Color.G) / 255.0 / a0, float32(s0.Color.B) / 255.0 / a0}, - "C1": pdfArray{float32(s1.Color.R) / 255.0 / a1, float32(s1.Color.G) / 255.0 / a1, float32(s1.Color.B) / 255.0 / a1}, - } -} - -*/ diff --git a/paint/pdf/paint.go b/paint/pdf/paint.go new file mode 100644 index 0000000000..4ccfa86535 --- /dev/null +++ b/paint/pdf/paint.go @@ -0,0 +1,295 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package pdf + +import ( + "fmt" + "image" + "image/color" + "log/slog" + "reflect" + + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles" +) + +// SetFill sets the fill style values where different from current. +func (w *pdfPage) SetFill(fill *styles.Fill) { + csty := w.style() + if csty.Fill.Color != fill.Color || csty.Fill.Opacity != fill.Opacity { + w.SetFillColor(fill) + } + csty.Fill = *fill +} + +// SetAlpha sets the transparency value. +func (w *pdfPage) SetAlpha(alpha float32) { + gs := w.getOpacityGS(alpha) + fmt.Fprintf(w, " /%v gs", gs) +} + +func alphaNorm(c uint8, a float32) dec { + if a == 0 { + return dec(0) + } + return dec(float32(c) / 255.0 / a) +} + +func alphaNormColor(c color.RGBA, a float32) [3]dec { + var v [3]dec + v[0] = alphaNorm(c.R, a) + v[1] = alphaNorm(c.G, a) + v[2] = alphaNorm(c.B, a) + return v +} + +// SetFillColor sets the filling color (image). +func (w *pdfPage) SetFillColor(fill *styles.Fill) { + switch x := fill.Color.(type) { + // todo: image + case *gradient.Linear: + fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(x)) + case *gradient.Radial: + fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(x)) + case *image.Uniform: + var clr color.RGBA + if x != nil { + clr = colors.ApplyOpacity(colors.AsRGBA(x), fill.Opacity) + } + a := float32(clr.A) / 255.0 + if a == 0 { + fmt.Fprintf(w, " 0 g") + } else if clr.R == clr.G && clr.R == clr.B { + fmt.Fprintf(w, " %v g", alphaNorm(clr.R, a)) + } else { + v := alphaNormColor(clr, a) + fmt.Fprintf(w, " %v %v %v rg", v[0], v[1], v[2]) + } + w.SetAlpha(a) + } +} + +// SetStroke sets the stroke style values where different from current. +func (w *pdfPage) SetStroke(stroke *styles.Stroke) { + csty := w.style() + if csty.Stroke.Color != stroke.Color || csty.Stroke.Opacity != stroke.Opacity { + w.SetStrokeColor(stroke) + } + if csty.Stroke.Width.Dots != stroke.Width.Dots { + w.SetStrokeWidth(stroke.Width.Dots) + } + if csty.Stroke.Cap != stroke.Cap { + w.SetStrokeCap(stroke.Cap) + } + if csty.Stroke.Join != stroke.Join || (csty.Stroke.Join == ppath.JoinMiter && csty.Stroke.MiterLimit != stroke.MiterLimit) { + w.SetStrokeJoin(stroke.Join, stroke.MiterLimit) + } + if len(stroke.Dashes) > 0 { // always do + w.SetDashes(stroke.DashOffset, stroke.Dashes) + } else { + if len(csty.Stroke.Dashes) > 0 { + w.SetDashes(0, nil) + } + } + csty.Stroke = *stroke +} + +// SetStrokeColor sets the stroking color (image). +func (w *pdfPage) SetStrokeColor(stroke *styles.Stroke) { + switch x := stroke.Color.(type) { + case *gradient.Linear: + case *gradient.Radial: + // TODO: should we unset cs? + // fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(stroke.Gradient)) + case *image.Uniform: + clr := colors.ApplyOpacity(colors.AsRGBA(x), stroke.Opacity) + a := float32(clr.A) / 255.0 + if clr.R == clr.G && clr.R == clr.B { + fmt.Fprintf(w, " %v G", alphaNorm(clr.R, a)) + } else { + v := alphaNormColor(clr, a) + fmt.Fprintf(w, " %v %v %v RG", v[0], v[1], v[2]) + } + w.SetAlpha(a) + } +} + +// SetStrokeWidth sets the stroke width. +func (w *pdfPage) SetStrokeWidth(lineWidth float32) { + fmt.Fprintf(w, " %v w", dec(lineWidth)) +} + +// SetStrokeCap sets the stroke cap type. +func (w *pdfPage) SetStrokeCap(capper ppath.Caps) { + var lineCap int + switch capper { + case ppath.CapButt: + lineCap = 0 + case ppath.CapRound: + lineCap = 1 + case ppath.CapSquare: + lineCap = 2 + default: + slog.Error("pdfWriter", "StrokeCap not supported", capper) + } + fmt.Fprintf(w, " %d J", lineCap) +} + +// SetStrokeJoin sets the stroke join type. +func (w *pdfPage) SetStrokeJoin(joiner ppath.Joins, miterLimit float32) { + var lineJoin int + switch joiner { + case ppath.JoinBevel: + lineJoin = 2 + case ppath.JoinRound: + lineJoin = 1 + case ppath.JoinMiter: + lineJoin = 0 + default: + slog.Error("pdfWriter", "StrokeJoin not supported", joiner) + } + fmt.Fprintf(w, " %d j", lineJoin) + if lineJoin == 0 { + fmt.Fprintf(w, " %v M", dec(miterLimit)) + } +} + +// SetDashes sets the dash phase and array. +func (w *pdfPage) SetDashes(dashPhase float32, dashArray []float32) { + if len(dashArray)%2 == 1 { + dashArray = append(dashArray, dashArray...) + } + + // PDF can't handle negative dash phases + if dashPhase < 0.0 { + totalLength := float32(0.0) + for _, dash := range dashArray { + totalLength += dash + } + for dashPhase < 0.0 { + dashPhase += totalLength + } + } + + dashes := append(dashArray, dashPhase) + if len(dashes) == 1 { + fmt.Fprintf(w, " [] 0 d") + dashes[0] = 0.0 + } else { + fmt.Fprintf(w, " [%v", dec(dashes[0])) + for _, dash := range dashes[1 : len(dashes)-1] { + fmt.Fprintf(w, " %v", dec(dash)) + } + fmt.Fprintf(w, "] %v d", dec(dashes[len(dashes)-1])) + } +} + +func (w *pdfPage) getOpacityGS(a float32) pdfName { + if name, ok := w.graphicsStates[a]; ok { + return name + } + name := pdfName(fmt.Sprintf("A%d", len(w.graphicsStates))) + w.graphicsStates[a] = name + + if _, ok := w.resources["ExtGState"]; !ok { + w.resources["ExtGState"] = pdfDict{} + } + w.resources["ExtGState"].(pdfDict)[name] = pdfDict{ + "CA": a, + "ca": a, + } + return name +} + +func (w *pdfPage) getPattern(gr gradient.Gradient) pdfName { + // TODO: support patterns/gradients with alpha channel + shading := pdfDict{ + "ColorSpace": pdfName("DeviceRGB"), + } + switch g := gr.(type) { + case *gradient.Linear: + shading["ShadingType"] = 2 + shading["Coords"] = pdfArray{g.Start.X, g.Start.Y, g.End.X, g.End.Y} + shading["Function"] = patternStopsFunction(g.Stops) + shading["Extend"] = pdfArray{true, true} + case *gradient.Radial: + shading["ShadingType"] = 3 + shading["Coords"] = pdfArray{g.Center.X, g.Center.Y, g.Radius.X, g.Focal.X, g.Focal.Y, g.Radius.Y} + shading["Function"] = patternStopsFunction(g.Stops) + shading["Extend"] = pdfArray{true, true} + } + pattern := pdfDict{ + "Type": pdfName("Pattern"), + "PatternType": 2, + "Shading": shading, + } + + if _, ok := w.resources["Pattern"]; !ok { + w.resources["Pattern"] = pdfDict{} + } + for name, pat := range w.resources["Pattern"].(pdfDict) { + if reflect.DeepEqual(pat, pattern) { + return name + } + } + name := pdfName(fmt.Sprintf("P%d", len(w.resources["Pattern"].(pdfDict)))) + w.resources["Pattern"].(pdfDict)[name] = pattern + return name +} + +func patternStopsFunction(stops []gradient.Stop) pdfDict { + if len(stops) < 2 { + return pdfDict{} + } + + fs := []pdfDict{} + encode := pdfArray{} + bounds := pdfArray{} + if !ppath.Equal(stops[0].Pos, 0.0) { + fs = append(fs, patternStopFunction(stops[0], stops[0])) + encode = append(encode, 0, 1) + bounds = append(bounds, stops[0].Pos) + } + for i := 0; i < len(stops)-1; i++ { + fs = append(fs, patternStopFunction(stops[i], stops[i+1])) + encode = append(encode, 0, 1) + if i != 0 { + bounds = append(bounds, stops[1].Pos) + } + } + if !ppath.Equal(stops[len(stops)-1].Pos, 1.0) { + fs = append(fs, patternStopFunction(stops[len(stops)-1], stops[len(stops)-1])) + encode = append(encode, 0, 1) + } + if len(fs) == 1 { + return fs[0] + } + return pdfDict{ + "FunctionType": 3, + "Domain": pdfArray{0, 1}, + "Encode": encode, + "Bounds": bounds, + "Functions": fs, + } +} + +func patternStopFunction(s0, s1 gradient.Stop) pdfDict { + a0 := float32(s0.Color.A) / 255.0 + a1 := float32(s1.Color.A) / 255.0 + c0 := alphaNormColor(s0.Color, a0) + c1 := alphaNormColor(s1.Color, a1) + return pdfDict{ + "FunctionType": 2, + "Domain": pdfArray{0, 1}, + "N": 1, + "C0": pdfArray{c0[0], c0[1], c0[2]}, + "C1": pdfArray{c1[0], c1[1], c1[2]}, + } +} diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 0a37b0039c..552c868425 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -14,6 +14,7 @@ import ( "testing" "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" @@ -53,6 +54,40 @@ func TestPath(t *testing.T) { }) } +func TestGradientLinear(t *testing.T) { + RunTest(t, "gradient-linear", 50, 50, func(pd *PDF, sty *styles.Paint) { + // pd.PushTransform(math32.Translate2D(10, 5)) + p := ppath.New().Rectangle(0, 0, 30, 20) + gg := gradient.NewLinear().AddStop(colors.White, 0).AddStop(colors.Red, 1) + gg.Start.Set(10, 20) + gg.End.Set(40, 20) + sty.Stroke.Color = colors.Uniform(colors.Blue) + sty.Fill.Color = gg + sty.Stroke.Width.Px(2) + sty.ToDots() + + pd.Path(*p, sty, math32.Translate2D(10, 20)) + // pd.PopStack() + }) +} + +func TestGradientRadial(t *testing.T) { + RunTest(t, "gradient-radial", 50, 50, func(pd *PDF, sty *styles.Paint) { + p := ppath.New().Rectangle(0, 0, 30, 20) + // todo: this is a different definition than ours.. + gg := gradient.NewRadial().AddStop(colors.White, 0).AddStop(colors.Red, 1) + gg.Center.Set(25, 20) + gg.Focal = gg.Center + gg.Radius.Set(15, 5) + sty.Stroke.Color = colors.Uniform(colors.Blue) + sty.Fill.Color = gg + sty.Stroke.Width.Px(2) + sty.ToDots() + + pd.Path(*p, sty, math32.Translate2D(10, 20)) + }) +} + func TestText(t *testing.T) { RunTest(t, "text", 300, 300, func(pd *PDF, sty *styles.Paint) { prv := UseStandardFonts() diff --git a/paint/pdf/text.go b/paint/pdf/text.go index 514c9c6e8e..47215e3852 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -8,8 +8,10 @@ package pdf import ( + "fmt" "image" + "cogentcore.org/core/base/errors" "cogentcore.org/core/colors" "cogentcore.org/core/math32" "cogentcore.org/core/paint/ppath" @@ -19,6 +21,7 @@ import ( "cogentcore.org/core/text/shaped/shapers/shapedgt" "cogentcore.org/core/text/text" "cogentcore.org/core/text/textpos" + "golang.org/x/text/encoding/charmap" ) // Text renders text to the canvas using a transformation matrix, @@ -226,3 +229,147 @@ func (r *PDF) links(lns *shaped.Lines, m math32.Matrix2, pos math32.Vector2) { r.w.AddLink(lk.URL, rb) } } + +// SetFont sets the font. +func (w *pdfPage) SetFont(sty *rich.Style, tsty *text.Style) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + size := tsty.FontHeight(sty) // * w.pdf.globalScale + ref := w.pdf.getFont(sty, tsty) + if _, ok := w.resources["Font"]; !ok { + w.resources["Font"] = pdfDict{} + } else { + for name, fontRef := range w.resources["Font"].(pdfDict) { + if ref == fontRef { + fmt.Fprintf(w, " /%v %v Tf", name, dec(size)) + return nil + } + } + } + + name := pdfName(fmt.Sprintf("F%d", len(w.resources["Font"].(pdfDict)))) + w.resources["Font"].(pdfDict)[name] = ref + fmt.Fprintf(w, " /%v %v Tf", name, dec(size)) + return nil +} + +// SetTextPosition sets the text offset position. +func (w *pdfPage) SetTextPosition(off math32.Vector2) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + do := off.Sub(w.textPosition) + // and finally apply an offset from there, in reverse for Y + fmt.Fprintf(w, " %v %v Td", dec(do.X), dec(-do.Y)) + w.textPosition = off + return nil +} + +// SetTextRenderMode sets the text rendering mode. +// 0 = fill text, 1 = stroke text, 2 = fill, then stroke. +// higher numbers support clip path. +func (w *pdfPage) SetTextRenderMode(mode int) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + fmt.Fprintf(w, " %d Tr", mode) + w.textRenderMode = mode + return nil +} + +// SetTextCharSpace sets the text character spacing. +func (w *pdfPage) SetTextCharSpace(space float32) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + fmt.Fprintf(w, " %v Tc", dec(space)) + w.textCharSpace = space + return nil +} + +// StartTextObject starts a text object, adding to the graphics +// CTM transform matrix as given by the arg, and setting an inverting +// text transform, so text is rendered upright. +func (w *pdfPage) StartTextObject(m math32.Matrix2) error { + if w.inTextObject { + return errors.Log(errors.New("pdfWriter: already in text object")) + } + // set the graphics transform to m first + w.PushTransform(m) + fmt.Fprintf(w, " BT") + // then apply an inversion text matrix + tm := math32.Scale2D(1, -1) + fmt.Fprintf(w, " %s Tm", mat2(tm)) + w.inTextObject = true + w.textPosition = math32.Vector2{} + return nil +} + +// EndTextObject ends a text object. +func (w *pdfPage) EndTextObject() error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + fmt.Fprintf(w, " ET") + w.PopStack() + w.inTextObject = false + return nil +} + +// WriteText writes text using current text style. +func (w *pdfPage) WriteText(tx string) error { + if !w.inTextObject { + return errors.Log(errors.New("pdfWriter: must be in text object")) + } + if len(tx) == 0 { + return nil + } + + first := true + write := func(s string) { + if first { + fmt.Fprintf(w, "(") + first = false + } else { + fmt.Fprintf(w, " (") + } + rs := []rune(s) + for _, r := range rs { + c, ok := charmap.Windows1252.EncodeRune(r) + if !ok { + if '\u2000' <= r && r <= '\u200A' { + c = ' ' + } + } + switch c { + case '\n': + w.WriteByte('\\') + w.WriteByte('n') + case '\r': + w.WriteByte('\\') + w.WriteByte('r') + case '\t': + w.WriteByte('\\') + w.WriteByte('t') + case '\b': + w.WriteByte('\\') + w.WriteByte('b') + case '\f': + w.WriteByte('\\') + w.WriteByte('f') + case '\\', '(', ')': + w.WriteByte('\\') + w.WriteByte(c) + default: + w.WriteByte(c) + } + } + fmt.Fprintf(w, ")") + } + + fmt.Fprintf(w, "[") + write(tx) + fmt.Fprintf(w, "]TJ") + return nil +} diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 0ec17e6f29..3add282d08 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -176,6 +176,8 @@ func (w *pdfWriter) writeVal(i interface{}) { } case int: w.write("%d", v) + case dec: + w.write("%v", v) case float32: w.write("%v", dec(v)) case float64: diff --git a/svg/testdata/svg/test.svg b/svg/testdata/svg/test.svg index 5679868eea..c0d6c71ae4 100644 --- a/svg/testdata/svg/test.svg +++ b/svg/testdata/svg/test.svg @@ -15,7 +15,7 @@ offset="0" id="stop5469" /> diff --git a/text/paginate/options.go b/text/paginate/options.go index b7de958483..4009167e78 100644 --- a/text/paginate/options.go +++ b/text/paginate/options.go @@ -12,18 +12,13 @@ import ( "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/printer" - "cogentcore.org/core/text/rich" ) // Options has the parameters for pagination. type Options struct { - // FontFamily specifies the default font family to apply - // to all core.Text elements. - FontFamily rich.Family - - // FontSize specifies the default font size to apply - // to all core.Text elements, if non-zero. - FontSize units.Value + // FontScale is an additional font scaling factor to apply. + // This is used in content to reverse the DocsFontSize factor, for example. + FontScale float32 // Title generates the title contents for the first page, // into the given page body frame. @@ -57,6 +52,7 @@ func NewOptions() Options { } func (o *Options) Defaults() { + o.FontScale = 1 o.Footer = CenteredPageNumber } diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index 9f0879b3db..c6da882fd3 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -13,6 +13,7 @@ import ( "cogentcore.org/core/paint/renderers/pdfrender" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/printer" "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -85,21 +86,29 @@ func (p *pager) assemble() { p.ins = append([]core.Widget{tf.This.(core.Widget)}, p.ins...) tf.StyleTree() } - for _, in := range p.ins { + fsc := printer.Settings.FontScale() * p.opts.FontScale + for ii, in := range p.ins { iw := core.AsWidget(in) iw.FinalStyler(func(s *styles.Style) { s.Min.X.Dot(p.opts.BodyDots.X) s.Min.Y.Dot(p.opts.BodyDots.Y) }) + if p.opts.Title != nil && ii == 0 { // don't restyle the title + continue + } iw.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool { if tx, ok := cwb.This.(*core.Text); ok { - if tx.Styles.Font.Family == rich.SansSerif { - if _, ok := cwb.Parent.(*core.Frame); ok { // not inside buttons etc - cwb.Styler(func(s *styles.Style) { - s.Font.Family = p.opts.FontFamily - }) - } + if _, ok := cwb.Parent.(*core.Frame); ok { // not inside buttons etc + cwb.Styler(func(s *styles.Style) { + if tx.Styles.Font.Family == rich.SansSerif { + s.Font.Family = printer.Settings.FontFamily + } + if tx.Type == core.TextBodyLarge { + s.Text.LineHeight = printer.Settings.LineHeight + } + s.Font.Size.Value *= fsc + }) } } return true diff --git a/text/paginate/runners.go b/text/paginate/runners.go index 2290b996c3..527baeabe9 100644 --- a/text/paginate/runners.go +++ b/text/paginate/runners.go @@ -9,6 +9,7 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/styles" + "cogentcore.org/core/text/printer" "cogentcore.org/core/text/rich" "cogentcore.org/core/text/text" ) @@ -50,15 +51,15 @@ func HeaderLeftPageNumber(header string) func(frame *core.Frame, opts *Options, }) core.NewText(fr).SetText(header).Styler(func(s *styles.Style) { s.SetTextWrap(false) - s.Font.Family = opts.FontFamily + s.Font.Family = printer.Settings.FontFamily s.Font.Slant = rich.Italic - s.Font.Size.Pt(11) + s.Font.Size = printer.Settings.FontSize }) core.NewStretch(fr) core.NewText(fr).SetText(strconv.Itoa(pageNo)).Styler(func(s *styles.Style) { s.SetTextWrap(false) - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(11) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize }) core.NewSpace(frame).Styler(func(s *styles.Style) { // space after s.Min.Y.Em(3) @@ -84,23 +85,25 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun s.Min.Y.Em(.1) }) core.NewText(fr).SetText(title).Styler(func(s *styles.Style) { - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(16) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize + s.Font.Size.Value *= 16.0 / 11 s.Text.Align = text.Center }) if authors != "" { core.NewText(fr).SetText(authors).Styler(func(s *styles.Style) { - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(11) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize + s.Font.Size.Value *= 12.0 / 11 s.Text.Align = text.Center }) } if affiliations != "" { core.NewText(fr).SetText(affiliations).Styler(func(s *styles.Style) { - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(10) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize s.Text.Align = text.Center s.Text.LineHeight = 1.1 }) @@ -109,30 +112,32 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun if date != "" { core.NewText(fr).SetText(date).Styler(func(s *styles.Style) { - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(10) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize s.Text.Align = text.Center }) } if url != "" { core.NewText(fr).SetText(url).Styler(func(s *styles.Style) { - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(10) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize s.Text.Align = text.Center }) } if abstract != "" { core.NewText(fr).SetText("Abstract:").Styler(func(s *styles.Style) { - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(11) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize + s.Font.Size.Value *= 12.0 / 11 s.Font.Weight = rich.Bold s.Align.Self = styles.Start }) core.NewText(fr).SetText(abstract).Styler(func(s *styles.Style) { - s.Font.Family = opts.FontFamily - s.Font.Size.Pt(10) + s.Font.Family = printer.Settings.FontFamily + s.Font.Size = printer.Settings.FontSize + s.Text.LineHeight = printer.Settings.LineHeight s.Align.Self = styles.Start }) } diff --git a/text/printer/settings.go b/text/printer/settings.go index 5ed4714151..b1c7f598e5 100644 --- a/text/printer/settings.go +++ b/text/printer/settings.go @@ -14,6 +14,7 @@ import ( "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" "cogentcore.org/core/system" + "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -37,6 +38,21 @@ type SettingsData struct { // Margins specify the page margins in the size units. Margins sides.Floats `display:"inline"` + + // FontFamily specifies the font family to use for printing. + // The default SansSerif font used on screen may not be desired + // for printouts, where Serif is more typically used. + FontFamily rich.Family + + // FontSize specifies the base font size to use for scaling printed + // text output (i.e., the default Text font will be this size, with + // larger elements scaled appropriately). + FontSize units.Value + + // LineHeight is the default line height for standard text elements, + // in proportion to the font size (e.g., 1.25), which determines the + // spacing between lines. + LineHeight float32 } func (ps *SettingsData) Defaults() { @@ -49,6 +65,9 @@ func (ps *SettingsData) Defaults() { case units.UnitPx: ps.Margins.Set(24) } + ps.FontFamily = rich.Serif + ps.FontSize.Pt(11) + ps.LineHeight = 1.25 ps.Update() } @@ -90,3 +109,11 @@ func (ps *SettingsData) ToDots(un *units.Context) (size, body math32.Vector2, ma body.Y = size.Y - (margins.Top + margins.Bottom) return } + +// FontScale returns the scaling factor based on FontSize, +// relative to the core default font size of 16 Dp. +func (ps *SettingsData) FontScale() float32 { + uc := units.NewContext() + sc := uc.Convert(16, units.UnitDp, ps.FontSize.Unit) + return ps.FontSize.Value / sc +} From 75097f628e9ccc440250de16abdecd3540f81828 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 12 Oct 2025 10:02:51 +0200 Subject: [PATCH 54/99] pdf: even more printer formatting settings including header styling function --- content/buttons.go | 2 +- content/examples/basic/content/button.md | 14 ++++ content/settings.go | 1 + paint/pdf/font.go | 81 ++++++++++++++++++++++++ paint/pdf/writer.go | 77 ---------------------- text/paginate/README.md | 12 ++-- text/paginate/options.go | 4 ++ text/paginate/pdf.go | 3 + text/paginate/runners.go | 28 ++++++++ 9 files changed, 140 insertions(+), 82 deletions(-) diff --git a/content/buttons.go b/content/buttons.go index 50cf3db8da..d58a32c10e 100644 --- a/content/buttons.go +++ b/content/buttons.go @@ -75,7 +75,7 @@ func (ct *Content) MakeToolbar(p *tree.Plan) { }) }) tree.Add(p, func(w *core.Button) { - w.SetText("PDF").SetIcon(icons.PictureAsPdf).SetTooltip("PDF generates and opens / downloads the current page as a printable PDF file") + w.SetText("PDF").SetIcon(icons.PictureAsPdf).SetTooltip("PDF generates and opens / downloads the current page as a printable PDF file. See the Settings/Printer panel (Command+,) for settings.") w.OnClick(func(e events.Event) { ct.PagePDF("pdfs") }) diff --git a/content/examples/basic/content/button.md b/content/examples/basic/content/button.md index 90c21f54d0..6a5b4b13b8 100644 --- a/content/examples/basic/content/button.md +++ b/content/examples/basic/content/button.md @@ -100,6 +100,20 @@ Action and menu buttons are the most minimal buttons, and they are typically onl core.NewButton(b).SetType(core.ButtonAction).SetText("Action") ``` +### Third level header + +This is content at the third level. + +#### Fourth level header + +This is content at the fourth level. wow. + +##### Fifth level header + +Now this is taking things a bit far. + +## Lists are like this + * List item 1 * Sub list item 1 * Sub list item 2 diff --git a/content/settings.go b/content/settings.go index 5411eee148..9abe2bc62b 100644 --- a/content/settings.go +++ b/content/settings.go @@ -56,6 +56,7 @@ func (s *SettingsData) Defaults() { dt = time.Now().Format("January 2, 2006") } ps.PDF.Title = paginate.CenteredTitle(pt, curPage.Authors, curPage.Affiliations, ura, dt, curPage.Abstract) + ps.PDF.TextStyler = paginate.APAHeaders return ps } } diff --git a/paint/pdf/font.go b/paint/pdf/font.go index bd351e3d69..b47ecd3dc1 100644 --- a/paint/pdf/font.go +++ b/paint/pdf/font.go @@ -7,6 +7,87 @@ package pdf +import ( + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" +) + +func standardFontName(sty *rich.Style) string { + name := "Helvetica" + switch sty.Family { + case rich.SansSerif: + name = "Helvetica" + case rich.Serif: + name = "Times" + case rich.Monospace: + name = "Courier" + case rich.Cursive: + name = "ZapfChancery" + case rich.Math: + name = "Symbol" + case rich.Emoji: + name = "ZapfDingbats" + } + bname := name + if sty.Weight > rich.Medium { + name += "-Bold" + if sty.Slant == rich.Italic { + if bname == "Times" { + name += "Italic" + } else { + name += "Oblique" + } + } + } else { + if sty.Slant == rich.Italic { + if bname == "Times" { + name += "-Italic" + } else { + name += "-Oblique" + } + } + } + if name == "Times" { + name = "Times-Roman" // ugh + } + return name +} + +func (w *pdfWriter) getFont(sty *rich.Style, tsty *text.Style) pdfRef { + if sty.Family != rich.Custom { + stdFont := standardFontName(sty) + if ref, ok := w.fontsStd[stdFont]; ok { + return ref + } + + dict := pdfDict{ + "Type": pdfName("Font"), + "Subtype": pdfName("Type1"), + "BaseFont": pdfName(stdFont), + "Encoding": pdfName("WinAnsiEncoding"), + } + ref := w.writeObject(dict) + w.fontsStd[stdFont] = ref + return ref + } + // todo: deal with custom + /* + fonts := w.fontsH + if vertical { + fonts = w.fontsV + } + if ref, ok := fonts[font]; ok { + return ref + } + w.objOffsets = append(w.objOffsets, 0) + ref := pdfRef(len(w.objOffsets)) + fonts[font] = ref + w.fontSubset[font] = ppath.NewFontSubsetter() + return ref + */ + return 0 +} + /* func (w *pdfWriter) writeFont(ref pdfRef, font *text.Font, vertical bool) { // subset the font, we only write the used characters to the PDF CMap object to reduce its diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 3add282d08..25c9fe1845 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -25,8 +25,6 @@ import ( "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" - "cogentcore.org/core/text/rich" - "cogentcore.org/core/text/text" "golang.org/x/exp/maps" ) @@ -304,81 +302,6 @@ func (w *pdfWriter) writeObject(val interface{}) pdfRef { return pdfRef(len(w.objOffsets)) } -func standardFontName(sty *rich.Style) string { - name := "Helvetica" - switch sty.Family { - case rich.SansSerif: - name = "Helvetica" - case rich.Serif: - name = "Times" - case rich.Monospace: - name = "Courier" - case rich.Cursive: - name = "ZapfChancery" - case rich.Math: - name = "Symbol" - case rich.Emoji: - name = "ZapfDingbats" - } - if sty.Weight > rich.Medium { - name += "-Bold" - if sty.Slant == rich.Italic { - if name == "Times" { - name += "Italic" - } else { - name += "Oblique" - } - } - } else { - if sty.Slant == rich.Italic { - if name == "Times" { - name += "-Italic" - } else { - name += "-Oblique" - } - } - } - if name == "Times" { - name = "Times-Roman" // ugh - } - return name -} - -func (w *pdfWriter) getFont(sty *rich.Style, tsty *text.Style) pdfRef { - if sty.Family != rich.Custom { - stdFont := standardFontName(sty) - if ref, ok := w.fontsStd[stdFont]; ok { - return ref - } - - dict := pdfDict{ - "Type": pdfName("Font"), - "Subtype": pdfName("Type1"), - "BaseFont": pdfName(stdFont), - "Encoding": pdfName("WinAnsiEncoding"), - } - ref := w.writeObject(dict) - w.fontsStd[stdFont] = ref - return ref - } - // todo: deal with custom - /* - fonts := w.fontsH - if vertical { - fonts = w.fontsV - } - if ref, ok := fonts[font]; ok { - return ref - } - w.objOffsets = append(w.objOffsets, 0) - ref := pdfRef(len(w.objOffsets)) - fonts[font] = ref - w.fontSubset[font] = ppath.NewFontSubsetter() - return ref - */ - return 0 -} - // Close finished the document. func (w *pdfWriter) Close() error { if w.page != nil { diff --git a/text/paginate/README.md b/text/paginate/README.md index 6022b6ed1c..4e3e8aed23 100644 --- a/text/paginate/README.md +++ b/text/paginate/README.md @@ -2,7 +2,7 @@ The `paginate` package takes a set of input Widget trees and returns a corresponding set of page Frame widgets that fit within a specified height, with optional title, headers and footers. -The main purpose is for generating PDF output, via the PDF function, which installs default PDF fonts (Helvetica, Times, Courier) and renders output. +The main purpose is for generating PDF output, via the `PDF` function, which installs default PDF fonts (Helvetica, Times, Courier) and renders output. The first step involves extracting a list of leaf-level widgets from surrounding core.Frame elements, that are then processed by the layout function to fit into page-sized chunks. This can be controlled by the properties as described below. @@ -12,9 +12,13 @@ Properties can be set on widgets to inform the pagination process. This is done * `block` -- marks a Frame as a block that is not to be further extracted from in collecting leaves. Only Frame elements that have direction = Column are -* `float-top` -- marks a `block` frame to be floated to the top of a page - * `break` -- starts a new page before this element. -* `no-break-after` -- marks an element to not have a page break inserted after it. +* `no-break-after` -- marks an element to not have a page break inserted after it (e.g., for a header). + +## Options and settings + +The `Options` type specifies a number of optional formatting elements, which are used by the PDF generator to insert a title and running headers and footers, according to functions that can add any kind of text. See `runners.go` for some examples. + +The `printer.Settings` settings are used to determine page size and font formatting defaults. diff --git a/text/paginate/options.go b/text/paginate/options.go index 4009167e78..f3aaf024a7 100644 --- a/text/paginate/options.go +++ b/text/paginate/options.go @@ -20,6 +20,10 @@ type Options struct { // This is used in content to reverse the DocsFontSize factor, for example. FontScale float32 + // TextStyler is an optional text styling function to apply to text elements. + // Can be used to adjust the sizes and formatting of headers, for example. + TextStyler func(tx *core.Text) + // Title generates the title contents for the first page, // into the given page body frame. Title func(frame *core.Frame, opts *Options) diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index c6da882fd3..167b4b0c58 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -108,6 +108,9 @@ func (p *pager) assemble() { s.Text.LineHeight = printer.Settings.LineHeight } s.Font.Size.Value *= fsc + if p.opts.TextStyler != nil { + p.opts.TextStyler(tx) + } }) } } diff --git a/text/paginate/runners.go b/text/paginate/runners.go index 527baeabe9..ac74e41b73 100644 --- a/text/paginate/runners.go +++ b/text/paginate/runners.go @@ -144,3 +144,31 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun core.NewSpace(fr).Styler(func(s *styles.Style) { s.Min.Y.Em(1) }) } } + +// APAHeaders is a TextStyler function that sets APA-style headers based +// on the text.Type. The default material design header sizes used onscreen are +// generally too large for print. This is designed for content, where the +// second level header ## is used for most top-level headers within a page. +func APAHeaders(tx *core.Text) { + s := &tx.Styles + base := printer.Settings.FontSize + switch tx.Type { + case core.TextDisplaySmall: // h1, e.g., chapter level + s.Font.Size = base + s.Font.Size.Value *= 16.0 / 11.0 + s.Font.Weight = rich.Bold + case core.TextHeadlineMedium: // h2 + s.Font.Size = base + s.Font.Size.Value *= 14.0 / 11.0 + s.Font.Weight = rich.Bold + s.Align.Self = styles.Center + case core.TextTitleLarge: // h3 + s.Font.Size = base + s.Font.Size.Value *= 12.0 / 11.0 + s.Font.Weight = rich.Bold + case core.TextTitleMedium: // h4 + s.Font.Size = base + s.Font.Weight = rich.Bold + s.Font.Slant = rich.Italic + } +} From 84ffd3462f2bf48f562dbd9d0432e96589f47471 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 12 Oct 2025 12:13:25 +0200 Subject: [PATCH 55/99] pdf: table of contents outline generation in place --- core/text.go | 25 ++++++++++-- paint/pdf/links.go | 84 ++++++++++++++++++++++++++++++++++++++++ paint/pdf/pdf_test.go | 28 ++++++++++++++ paint/pdf/text.go | 17 +++++++- paint/pdf/writer.go | 13 +++++++ text/paginate/runners.go | 20 +++++++--- 6 files changed, 176 insertions(+), 11 deletions(-) diff --git a/core/text.go b/core/text.go index 9ee392df6f..6c0f82878e 100644 --- a/core/text.go +++ b/core/text.go @@ -7,7 +7,9 @@ package core import ( "fmt" "image" + "strconv" + "cogentcore.org/core/base/errors" "cogentcore.org/core/base/fileinfo/mimedata" "cogentcore.org/core/colors" "cogentcore.org/core/cursors" @@ -428,9 +430,26 @@ func (tx *Text) configTextSize(sz math32.Vector2) { sty, tsty := tx.Styles.NewRichText() tsty.Align, tsty.AlignV = text.Start, text.Start tx.paintText = tx.Scene.TextShaper().WrapLines(tx.richText, sty, tsty, sz) + tx.setAnchorFromProperties() +} + +// setAnchorFromProperties sets the Anchor string on paintText based on +// id and tag properties, which is needed for PDF print formatting. +// This is the only point at which we know we have the final +// shaped.Lines for a text item. +func (tx *Text) setAnchorFromProperties() { + anc := "" if id, ok := tx.Properties["id"]; ok { - tx.paintText.Anchor = id.(string) + anc = id.(string) + } + if t, ok := tx.Properties["tag"]; ok { + tag := t.(string) + if len(tag) > 1 && tag[0] == 'h' { // header + level := errors.Log1(strconv.Atoi(tag[1:])) + anc += fmt.Sprintf(";Header %d:%s", level, tx.Text) + } } + tx.paintText.Anchor = anc } // configTextAlloc is used for determining how much space the text @@ -455,9 +474,7 @@ func (tx *Text) configTextAlloc(sz math32.Vector2) math32.Vector2 { rsz = tx.paintText.Bounds.Size().Ceil() } tx.paintText = tsh.WrapLines(tx.richText, sty, tsty, rsz) - if id, ok := tx.Properties["id"]; ok { - tx.paintText.Anchor = id.(string) - } + tx.setAnchorFromProperties() return tx.paintText.Bounds.Size().Ceil() } diff --git a/paint/pdf/links.go b/paint/pdf/links.go index 453e1166fc..c7a61f1a9f 100644 --- a/paint/pdf/links.go +++ b/paint/pdf/links.go @@ -51,3 +51,87 @@ func (w *pdfPage) AddLink(uri string, rect math32.Box2) { } w.annots = append(w.annots, annot) } + +//////// outline + +// pdfOutline is one outline entry +type pdfOutline struct { + name string + level int + page int + ypos float32 // inverted y position + + parent int // index into outline list + count int + prev int + next int + first int + last int +} + +// AddOutline adds an outline element. level must be > 0 +func (w *pdfPage) AddOutline(name string, level int, ypos float32) { + ol := &pdfOutline{name: name, level: level, page: w.pageNo, ypos: w.height - w.pdf.globalScale*ypos, prev: -1, next: -1, first: -1, last: -1} + if len(w.pdf.outlines) == 0 { + or := &pdfOutline{name: "Contents", level: 0, page: 0, ypos: 0, prev: -1, next: -1, first: -1, last: -1} + w.pdf.outlines = append(w.pdf.outlines, or) + } + w.pdf.outlines = append(w.pdf.outlines, ol) +} + +// writeOutlines outputs all the outline elements, and returns the ref to the first one. +func (w *pdfWriter) writeOutlines() pdfRef { + lastLev := make(map[int]int) + level := 0 + n := len(w.outlines) + // note: this logic from fpdf + for i, o := range w.outlines { + if o.level > 0 { + parent := lastLev[o.level-1] + o.parent = parent + w.outlines[parent].last = i + w.outlines[parent].count++ + if o.level > level { + w.outlines[parent].first = i + } + } else { + o.parent = n + } + if o.level <= level && i > 0 { + prev := lastLev[o.level] + w.outlines[prev].next = i + o.prev = prev + } + lastLev[o.level] = i + level = o.level + } + var refs pdfArray + firstRef := pdfRef(len(w.objOffsets) + 1) + for i, o := range w.outlines { + od := pdfDict{ + "Title": o.name, + "Parent": firstRef + pdfRef(o.parent), + "Dest": pdfArray{w.pages[o.page], pdfName("XYZ"), 0, o.ypos, 0}, + "Count": o.count, + } + if o.prev != -1 { + od[pdfName("Prev")] = firstRef + pdfRef(o.prev) + } + if o.next != -1 { + od[pdfName("Next")] = firstRef + pdfRef(o.next) + } + if o.first != -1 { + od[pdfName("First")] = firstRef + pdfRef(o.first) + } + if o.last != -1 { + od[pdfName("Last")] = firstRef + pdfRef(o.last) + } + if i == 0 { + delete(od, "Parent") + delete(od, "Dest") + } + ref := w.writeObject(od) + refs = append(refs, ref) + } + return firstRef +} diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 552c868425..77b2c82a4d 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -172,6 +172,34 @@ func TestLinks(t *testing.T) { }) } +func TestOutline(t *testing.T) { + RunTest(t, "outline", 300, 300, func(pd *PDF, sty *styles.Paint) { + prv := UseStandardFonts() + sh := shaped.NewShaper() + m := math32.Identity2() + rsty := &sty.Font + tsty := &sty.Text + sz := math32.Vec2(250, 250) + + txt := func(src string, pos math32.Vector2) { + tx, err := htmltext.HTMLToRich([]byte(src), rsty, nil) + assert.NoError(t, err) + lns := sh.WrapLines(tx, rsty, tsty, sz) + pd.Text(sty, m, pos, lns) + } + txt("Heading 1", math32.Vec2(10, 10)) + pd.w.AddOutline("Heading 1", 1, 10) + txt("Sub Heading", math32.Vec2(10, 30)) + pd.w.AddOutline("Sub Heading 1", 2, 30) + txt("Another Sub Heading", math32.Vec2(10, 50)) + pd.w.AddOutline("Sub Heading 2", 2, 50) + txt("Heading 2", math32.Vec2(10, 70)) + pd.w.AddOutline("Heading 2", 1, 70) + + RestorePreviousFonts(prv) + }) +} + func TestLayers(t *testing.T) { RunTest(t, "layers", 300, 300, func(pd *PDF, sty *styles.Paint) { prv := UseStandardFonts() diff --git a/paint/pdf/text.go b/paint/pdf/text.go index 47215e3852..c714a7fb1e 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -10,6 +10,8 @@ package pdf import ( "fmt" "image" + "strconv" + "strings" "cogentcore.org/core/base/errors" "cogentcore.org/core/colors" @@ -28,7 +30,20 @@ import ( // (the translation component specifies the starting offset) func (r *PDF) Text(style *styles.Paint, m math32.Matrix2, pos math32.Vector2, lns *shaped.Lines) { if lns.Anchor != "" { - r.w.AddAnchor(lns.Anchor, pos) + anc := lns.Anchor + hidx := strings.Index(anc, ";Header ") + if hidx >= 0 { + if hidx > 0 { + r.w.AddAnchor(anc[:hidx], pos) + anc = anc[hidx:] + } + anc = anc[8:] + level := errors.Log1(strconv.Atoi(anc[0:1])) + anc = anc[2:] + r.w.AddOutline(anc, level, pos.Y) + } else { + r.w.AddAnchor(lns.Anchor, pos) + } } mt := m.Mul(math32.Translate2D(pos.X, pos.Y)) r.w.PushTransform(mt) diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 25c9fe1845..33f0f8e38a 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -50,6 +50,7 @@ type pdfWriter struct { images map[image.Image]pdfRef layers pdfLayers anchors pdfMap // things that can be linked to within doc + outlines []*pdfOutline compress bool subset bool title string @@ -342,6 +343,18 @@ func (w *pdfWriter) Close() error { catalog[pdfName("Names")] = pdfDict{"Dests": nmref} } + if len(w.outlines) > 0 { + firstRef := w.writeOutlines() + first := w.outlines[0] + cdict := w.writeObject(pdfDict{ + "Type:": pdfName("Outlines"), + "First": firstRef, + "Last": firstRef + pdfRef(first.last), + "Count": len(w.outlines), + }) + catalog[pdfName("Outlines")] = cdict + } + if len(w.layers.list) > 0 { var refs, off pdfArray for _, l := range w.layers.list { diff --git a/text/paginate/runners.go b/text/paginate/runners.go index ac74e41b73..ed4fb8e840 100644 --- a/text/paginate/runners.go +++ b/text/paginate/runners.go @@ -7,6 +7,7 @@ package paginate import ( "strconv" + "cogentcore.org/core/base/errors" "cogentcore.org/core/core" "cogentcore.org/core/styles" "cogentcore.org/core/text/printer" @@ -146,27 +147,34 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun } // APAHeaders is a TextStyler function that sets APA-style headers based -// on the text.Type. The default material design header sizes used onscreen are +// on the tag property. The default material design header sizes used onscreen are // generally too large for print. This is designed for content, where the // second level header ## is used for most top-level headers within a page. func APAHeaders(tx *core.Text) { + headerLevel := 0 + if t, ok := tx.Properties["tag"]; ok { + tag := t.(string) + if len(tag) > 1 && tag[0] == 'h' { + headerLevel = errors.Log1(strconv.Atoi(tag[1:])) + } + } s := &tx.Styles base := printer.Settings.FontSize - switch tx.Type { - case core.TextDisplaySmall: // h1, e.g., chapter level + switch headerLevel { + case 1: // e.g., chapter level s.Font.Size = base s.Font.Size.Value *= 16.0 / 11.0 s.Font.Weight = rich.Bold - case core.TextHeadlineMedium: // h2 + case 2: s.Font.Size = base s.Font.Size.Value *= 14.0 / 11.0 s.Font.Weight = rich.Bold s.Align.Self = styles.Center - case core.TextTitleLarge: // h3 + case 3: s.Font.Size = base s.Font.Size.Value *= 12.0 / 11.0 s.Font.Weight = rich.Bold - case core.TextTitleMedium: // h4 + case 4: s.Font.Size = base s.Font.Weight = rich.Bold s.Font.Slant = rich.Italic From 1a8978c36f86f97882fae01fbd3522eb6a547d78 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 12 Oct 2025 12:22:14 +0200 Subject: [PATCH 56/99] pdf: toc root points to start of doc --- paint/pdf/links.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/paint/pdf/links.go b/paint/pdf/links.go index c7a61f1a9f..74ee91ee4d 100644 --- a/paint/pdf/links.go +++ b/paint/pdf/links.go @@ -73,7 +73,7 @@ type pdfOutline struct { func (w *pdfPage) AddOutline(name string, level int, ypos float32) { ol := &pdfOutline{name: name, level: level, page: w.pageNo, ypos: w.height - w.pdf.globalScale*ypos, prev: -1, next: -1, first: -1, last: -1} if len(w.pdf.outlines) == 0 { - or := &pdfOutline{name: "Contents", level: 0, page: 0, ypos: 0, prev: -1, next: -1, first: -1, last: -1} + or := &pdfOutline{name: "Contents", level: 0, page: 0, ypos: w.height, prev: -1, next: -1, first: -1, last: -1} w.pdf.outlines = append(w.pdf.outlines, or) } w.pdf.outlines = append(w.pdf.outlines, ol) @@ -128,7 +128,6 @@ func (w *pdfWriter) writeOutlines() pdfRef { } if i == 0 { delete(od, "Parent") - delete(od, "Dest") } ref := w.writeObject(od) refs = append(refs, ref) From 591409667f3549e7e20365f998debcde16689e8f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 12 Oct 2025 14:29:05 +0200 Subject: [PATCH 57/99] pdf: tests identify that the nomial gradient coordinates need to be updated by the full transform. --- colors/gradient/linear.go | 11 +++++++++-- paint/painter.go | 18 ++++++++++-------- paint/pdf/context.go | 9 +++++++++ paint/pdf/paint.go | 24 ++++++++++++++++-------- paint/pdf/pdf.go | 20 +++++++++++++------- paint/pdf/pdf_test.go | 20 +++++++++++++------- paint/pdf/text.go | 8 ++++---- paint/renderers/pdfrender/pdfrender.go | 2 +- 8 files changed, 75 insertions(+), 37 deletions(-) diff --git a/colors/gradient/linear.go b/colors/gradient/linear.go index 285ab7700c..011005810f 100644 --- a/colors/gradient/linear.go +++ b/colors/gradient/linear.go @@ -14,11 +14,12 @@ import ( "cogentcore.org/core/math32" ) -// Linear represents a linear gradient. It implements the [image.Image] interface. +// Linear represents a linear gradient along linear axis from Start to End. +// It implements the [image.Image] interface. type Linear struct { //types:add -setters Base - // the starting point of the gradient (x1 and y1 in SVG) + // the starting point of the gradient axis (x1 and y1 in SVG). Start math32.Vector2 // the ending point of the gradient (x2 and y2 in SVG) @@ -74,6 +75,12 @@ func (l *Linear) Update(opacity float32, box math32.Box2, objTransform math32.Ma l.distanceLengthSquared = l.distance.LengthSquared() } +// TransformedAxis returns the Start and End axis points as transformed +// by the last Update call. +func (l *Linear) TransformedAxis() (start, end math32.Vector2) { + return l.rStart, l.rEnd +} + // At returns the color of the linear gradient at the given point func (l *Linear) At(x, y int) color.Color { switch len(l.Stops) { diff --git a/paint/painter.go b/paint/painter.go index 275635e08d..a22998bd72 100644 --- a/paint/painter.go +++ b/paint/painter.go @@ -57,7 +57,9 @@ func NewPainter(size math32.Vector2) *Painter { return pc } -func (pc *Painter) Transform() math32.Matrix2 { +// Cumulative returns the current cumulative transform function, +// including the current transform. +func (pc *Painter) Cumulative() math32.Matrix2 { return pc.Context().Cumulative.Mul(pc.Paint.Transform) } @@ -446,8 +448,8 @@ func (pc *Painter) DrawBox(pos, size math32.Vector2, img image.Image, op draw.Op if img == nil { img = colors.Uniform(color.RGBA{}) } - pos = pc.Transform().MulVector2AsPoint(pos) - size = pc.Transform().MulVector2AsVector(size) + pos = pc.Cumulative().MulVector2AsPoint(pos) + size = pc.Cumulative().MulVector2AsVector(size) br := math32.RectFromPosSizeMax(pos, size) cb := pc.Context().Bounds.Rect.ToRect() b := cb.Intersect(br) @@ -455,7 +457,7 @@ func (pc *Painter) DrawBox(pos, size math32.Vector2, img image.Image, op draw.Op return } if g, ok := img.(gradient.Gradient); ok { - g.Update(pc.Fill.Opacity, math32.B2FromRect(b), pc.Transform()) + g.Update(pc.Fill.Opacity, math32.B2FromRect(b), pc.Cumulative()) } else { img = gradient.ApplyOpacity(img, pc.Fill.Opacity) } @@ -521,7 +523,7 @@ func (pc *Painter) DrawImageAnchored(src image.Image, x, y, ax, ay float32) *pim s := src.Bounds().Size() x -= ax * float32(s.X) y -= ay * float32(s.Y) - m := pc.Transform().Translate(x, y) + m := pc.Cumulative().Translate(x, y) var pim *pimage.Params if pc.Mask == nil { pim = pimage.NewTransform(m, src.Bounds(), src, draw.Over) @@ -540,7 +542,7 @@ func (pc *Painter) DrawImageScaled(src image.Image, x, y, w, h float32) *pimage. isz := math32.FromPoint(s) isc := math32.Vec2(w, h).Div(isz) - m := pc.Transform().Translate(x, y).Scale(isc.X, isc.Y) + m := pc.Cumulative().Translate(x, y).Scale(isc.X, isc.Y) var pim *pimage.Params if pc.Mask == nil { pim = pimage.NewTransform(m, src.Bounds(), src, draw.Over) @@ -558,8 +560,8 @@ func (pc *Painter) BoundingBox(minX, minY, maxX, maxY float32) image.Rectangle { // if pc.Stroke.Color != nil {// todo // sw = 0.5 * pc.StrokeWidth() // } - tmin := pc.Transform().MulVector2AsPoint(math32.Vec2(minX, minY)) - tmax := pc.Transform().MulVector2AsPoint(math32.Vec2(maxX, maxY)) + tmin := pc.Cumulative().MulVector2AsPoint(math32.Vec2(minX, minY)) + tmax := pc.Cumulative().MulVector2AsPoint(math32.Vec2(maxX, maxY)) tp1 := math32.Vec2(tmin.X-sw, tmin.Y-sw).ToPointFloor() tp2 := math32.Vec2(tmax.X+sw, tmax.Y+sw).ToPointCeil() return image.Rect(tp1.X, tp1.Y, tp2.X, tp2.Y) diff --git a/paint/pdf/context.go b/paint/pdf/context.go index b48d152c0a..e60eea6e60 100644 --- a/paint/pdf/context.go +++ b/paint/pdf/context.go @@ -64,6 +64,15 @@ func (w *pdfPage) PushTransform(m math32.Matrix2) { w.SetTransform(m) } +// Cumulative returns the current cumulative transform. +func (w *pdfPage) Cumulative() math32.Matrix2 { + m := math32.Identity2() + for _, s := range w.stack { + m = m.Mul(s.Transform) + } + return m +} + // style() returns the currently active style func (w *pdfPage) style() *styles.Paint { ctx := w.stack.Peek() diff --git a/paint/pdf/paint.go b/paint/pdf/paint.go index 4ccfa86535..4e2abe7aab 100644 --- a/paint/pdf/paint.go +++ b/paint/pdf/paint.go @@ -16,15 +16,18 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/math32" "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" ) // SetFill sets the fill style values where different from current. -func (w *pdfPage) SetFill(fill *styles.Fill) { +// The bounds and matrix are required for gradient fills: pass identity +// if not available. +func (w *pdfPage) SetFill(fill *styles.Fill, bounds math32.Box2, m math32.Matrix2) { csty := w.style() if csty.Fill.Color != fill.Color || csty.Fill.Opacity != fill.Opacity { - w.SetFillColor(fill) + w.SetFillColor(fill, bounds, m) } csty.Fill = *fill } @@ -51,13 +54,13 @@ func alphaNormColor(c color.RGBA, a float32) [3]dec { } // SetFillColor sets the filling color (image). -func (w *pdfPage) SetFillColor(fill *styles.Fill) { +func (w *pdfPage) SetFillColor(fill *styles.Fill, bounds math32.Box2, m math32.Matrix2) { switch x := fill.Color.(type) { // todo: image case *gradient.Linear: - fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(x)) + fmt.Fprintf(w, " /Pattern cs /%v scn", w.gradientPattern(x, bounds, m)) case *gradient.Radial: - fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(x)) + fmt.Fprintf(w, " /Pattern cs /%v scn", w.gradientPattern(x, bounds, m)) case *image.Uniform: var clr color.RGBA if x != nil { @@ -107,7 +110,7 @@ func (w *pdfPage) SetStrokeColor(stroke *styles.Stroke) { case *gradient.Linear: case *gradient.Radial: // TODO: should we unset cs? - // fmt.Fprintf(w, " /Pattern cs /%v scn", w.getPattern(stroke.Gradient)) + // fmt.Fprintf(w, " /Pattern cs /%v scn", w.gradientPattern(stroke.Gradient)) case *image.Uniform: clr := colors.ApplyOpacity(colors.AsRGBA(x), stroke.Opacity) a := float32(clr.A) / 255.0 @@ -208,15 +211,20 @@ func (w *pdfPage) getOpacityGS(a float32) pdfName { return name } -func (w *pdfPage) getPattern(gr gradient.Gradient) pdfName { +func (w *pdfPage) gradientPattern(gr gradient.Gradient, bounds math32.Box2, m math32.Matrix2) pdfName { + // fbox := sc.GetPathExtent() + // lastRenderBBox := image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, + // Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} + gr.Update(1, bounds, m) // TODO: support patterns/gradients with alpha channel shading := pdfDict{ "ColorSpace": pdfName("DeviceRGB"), } switch g := gr.(type) { case *gradient.Linear: + s, e := g.TransformedAxis() shading["ShadingType"] = 2 - shading["Coords"] = pdfArray{g.Start.X, g.Start.Y, g.End.X, g.End.Y} + shading["Coords"] = pdfArray{s.X, s.Y, e.X, e.Y} shading["Function"] = patternStopsFunction(g.Stops) shading["Extend"] = pdfArray{true, true} case *gradient.Radial: diff --git a/paint/pdf/pdf.go b/paint/pdf/pdf.go index f1efff2347..da2099f805 100644 --- a/paint/pdf/pdf.go +++ b/paint/pdf/pdf.go @@ -160,8 +160,14 @@ func (r *PDF) PushTransform(m math32.Matrix2) { r.SetTransform(m) } -// Path renders a path to the canvas using a style and a transformation matrix. -func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { +// Cumulative returns the current cumulative transform. +func (r *PDF) Cumulative() math32.Matrix2 { + return r.w.Cumulative() +} + +// Path renders a path to the canvas using a style and an +// individual and cumulative transformation matrix (needed for fill) +func (r *PDF) Path(path ppath.Path, style *styles.Paint, bounds math32.Box2, tr, cum math32.Matrix2) { // PDFs don't support the arcs joiner, miter joiner (not clipped), // or miter joiner (clipped) with non-bevel fallback strokeUnsupported := false @@ -175,7 +181,7 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { // strokeUnsupported = true // } } - scale := math32.Sqrt(math32.Abs(m.Det())) + scale := math32.Sqrt(math32.Abs(tr.Det())) stk := style.Stroke stk.Width.Dots *= scale stk.DashOffset, stk.Dashes = ppath.ScaleDash(scale, stk.DashOffset, stk.Dashes) @@ -188,7 +194,7 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { //} closed := false - data := path.Clone().Transform(m).ToPDF() + data := path.Clone().Transform(tr).ToPDF() if 1 < len(data) && data[len(data)-1] == 'h' { data = data[:len(data)-2] closed = true @@ -221,7 +227,7 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { // return } if style.HasFill() && !style.HasStroke() { - r.w.SetFill(&style.Fill) + r.w.SetFill(&style.Fill, bounds, cum) r.w.Write([]byte(" ")) r.w.Write([]byte(data)) r.w.Write([]byte(" f")) @@ -245,7 +251,7 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { // todo: sameAlpha := true if sameAlpha { - r.w.SetFill(&style.Fill) + r.w.SetFill(&style.Fill, bounds, cum) r.w.SetStroke(&style.Stroke) r.w.Write([]byte(" ")) r.w.Write([]byte(data)) @@ -258,7 +264,7 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, m math32.Matrix2) { r.w.Write([]byte("*")) } } else { - r.w.SetFill(&style.Fill) + r.w.SetFill(&style.Fill, bounds, cum) r.w.Write([]byte(" ")) r.w.Write([]byte(data)) r.w.Write([]byte(" f")) diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 77b2c82a4d..20fecbd537 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -50,24 +50,28 @@ func TestPath(t *testing.T) { sty.Stroke.Width.Px(2) sty.ToDots() - pd.Path(*p, sty, math32.Translate2D(10, 20)) + tr := math32.Translate2D(10, 20) + pd.Path(*p, sty, math32.Box2{}, tr, tr) }) } func TestGradientLinear(t *testing.T) { RunTest(t, "gradient-linear", 50, 50, func(pd *PDF, sty *styles.Paint) { - // pd.PushTransform(math32.Translate2D(10, 5)) + pd.PushTransform(math32.Translate2D(10, 5).Scale(.5, .5)) p := ppath.New().Rectangle(0, 0, 30, 20) gg := gradient.NewLinear().AddStop(colors.White, 0).AddStop(colors.Red, 1) - gg.Start.Set(10, 20) - gg.End.Set(40, 20) + gg.Start.Set(0, 10) + gg.End.Set(30, 10) + gg.Units = gradient.UserSpaceOnUse sty.Stroke.Color = colors.Uniform(colors.Blue) sty.Fill.Color = gg sty.Stroke.Width.Px(2) sty.ToDots() - pd.Path(*p, sty, math32.Translate2D(10, 20)) - // pd.PopStack() + tr := math32.Translate2D(10, 20) + cum := pd.Cumulative() + pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr, cum.Mul(tr)) + pd.PopStack() }) } @@ -76,6 +80,7 @@ func TestGradientRadial(t *testing.T) { p := ppath.New().Rectangle(0, 0, 30, 20) // todo: this is a different definition than ours.. gg := gradient.NewRadial().AddStop(colors.White, 0).AddStop(colors.Red, 1) + gg.Units = gradient.UserSpaceOnUse gg.Center.Set(25, 20) gg.Focal = gg.Center gg.Radius.Set(15, 5) @@ -84,7 +89,8 @@ func TestGradientRadial(t *testing.T) { sty.Stroke.Width.Px(2) sty.ToDots() - pd.Path(*p, sty, math32.Translate2D(10, 20)) + tr := math32.Translate2D(10, 20) + pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr, tr) }) } diff --git a/paint/pdf/text.go b/paint/pdf/text.go index c714a7fb1e..e6a7ea4310 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -135,7 +135,7 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, psty := *style psty.Stroke.Color = run.StrokeColor psty.Fill.Color = fill - r.Path(*run.Math.Path, &psty, math32.Identity2()) + r.Path(*run.Math.Path, &psty, math32.B2FromFixed(run.Bounds()), math32.Identity2(), math32.Identity2()) r.w.PopStack() return } @@ -184,7 +184,7 @@ func (r *PDF) setTextStrokeColor(clr image.Image) { func (r *PDF) setTextFillColor(clr image.Image) { fc := r.w.style().Fill fc.Color = clr - r.w.SetFill(&fc) + r.w.SetFill(&fc, math32.Box2{}, math32.Identity2()) } // setTextStyle applies the given styles. @@ -217,7 +217,7 @@ func (r *PDF) strokeTextLine(m math32.Matrix2, sp, ep math32.Vector2, width floa sty.Stroke.Color = clr sty.Stroke.Dashes = dash p := ppath.New().Line(sp.X, sp.Y, ep.X, ep.Y) - r.Path(*p, sty, m) + r.Path(*p, sty, math32.Box2{}, m, m) } // FillBox fills a box in the given color. @@ -227,7 +227,7 @@ func (r *PDF) FillBox(m math32.Matrix2, bb math32.Box2, clr image.Image) { sty.Fill.Color = clr sz := bb.Size() p := ppath.New().Rectangle(bb.Min.X, bb.Min.Y, sz.X, sz.Y) - r.Path(*p, sty, m) + r.Path(*p, sty, bb, m, m) } func (r *PDF) links(lns *shaped.Lines, m math32.Matrix2, pos math32.Vector2) { diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index a029b83e0d..05920da982 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -130,7 +130,7 @@ func (rs *Renderer) PopStack() int { func (rs *Renderer) RenderPath(pt *render.Path) { p := pt.Path pc := &pt.Context - rs.PDF.Path(p, &pc.Style, pc.Transform) + rs.PDF.Path(p, &pc.Style, pc.Bounds.Rect, pc.Transform, pc.Cumulative) } func (rs *Renderer) PushContext(pt *render.ContextPush) { From dcae805c89d357a4fc71e125221c7b59f6c08beb Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 13 Oct 2025 10:11:19 +0200 Subject: [PATCH 58/99] pdf: gradients mostly working --- colors/gradient/radial.go | 6 ++ paint/pdf/context.go | 2 +- paint/pdf/paint.go | 131 ++++++++++++++++++++++++- paint/pdf/pdf.go | 130 ++---------------------- paint/pdf/pdf_test.go | 7 +- paint/pdf/text.go | 6 +- paint/pdf/writer.go | 1 + paint/renderers/pdfrender/pdfrender.go | 2 +- svg/svg_test.go | 2 +- svg/testdata/svg/test.svg | 68 +++++++------ 10 files changed, 191 insertions(+), 164 deletions(-) diff --git a/colors/gradient/radial.go b/colors/gradient/radial.go index d87c0b655c..86a33e7ebe 100644 --- a/colors/gradient/radial.go +++ b/colors/gradient/radial.go @@ -96,6 +96,12 @@ func (r *Radial) Update(opacity float32, box math32.Box2, objTransform math32.Ma r.rCenter, r.rFocal, r.rRadius = c, f, rs } +// TransformedCoords returns the coordinate points as transformed +// by the last Update call. +func (r *Radial) TransformedCoords() (center, focal, radius math32.Vector2) { + return r.rCenter, r.rFocal, r.rRadius +} + const epsilonF = 1e-5 // At returns the color of the radial gradient at the given point diff --git a/paint/pdf/context.go b/paint/pdf/context.go index e60eea6e60..50a1ff6312 100644 --- a/paint/pdf/context.go +++ b/paint/pdf/context.go @@ -54,7 +54,7 @@ func (w *pdfPage) SetTransform(m math32.Matrix2) { } fmt.Fprintf(w, " %s cm", mat2(m2)) ctx := w.stack.Peek() - ctx.Transform = ctx.Transform.Mul(m2) + ctx.Transform = m2 // not cumulative! } // PushTransform adds a graphics stack push (q) and then diff --git a/paint/pdf/paint.go b/paint/pdf/paint.go index 4e2abe7aab..2cd8879a9c 100644 --- a/paint/pdf/paint.go +++ b/paint/pdf/paint.go @@ -21,6 +21,128 @@ import ( "cogentcore.org/core/styles" ) +// Path renders a path to the canvas using a style and an +// individual matrix (needed for fill) +func (r *PDF) Path(path ppath.Path, style *styles.Paint, bounds math32.Box2, tr math32.Matrix2) { + // PDFs don't support the arcs joiner, miter joiner (not clipped), + // or miter joiner (clipped) with non-bevel fallback + strokeUnsupported := false + if style.Stroke.Join == ppath.JoinArcs { + strokeUnsupported = true + } else if style.Stroke.Join == ppath.JoinMiter { + if style.Stroke.MiterLimit == 0 { + strokeUnsupported = true + } + // } else if _, ok := miter.GapJoiner.(canvas.BevelJoiner); !ok { + // strokeUnsupported = true + // } + } + scale := math32.Sqrt(math32.Abs(tr.Det())) + stk := style.Stroke + stk.Width.Dots *= scale + stk.DashOffset, stk.Dashes = ppath.ScaleDash(scale, stk.DashOffset, stk.Dashes) + + // PDFs don't support connecting first and last dashes if path is closed, + // so we move the start of the path if this is the case + // TODO: closing dashes + //if style.DashesClose { + // strokeUnsupported = true + //} + + closed := false + data := path.Clone().Transform(tr).ToPDF() + if 1 < len(data) && data[len(data)-1] == 'h' { + data = data[:len(data)-2] + closed = true + } + + if style.HasStroke() && strokeUnsupported { + // todo: handle with optional inclusion of stroke function as _ import + /* // style.HasStroke() && strokeUnsupported + if style.HasFill() { + r.w.SetFill(style.Fill) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) + if style.Fill.Rule == canvas.EvenOdd { + r.w.Write([]byte("*")) + } + } + + // stroke settings unsupported by PDF, draw stroke explicitly + if style.IsDashed() { + path = path.Dash(style.DashOffset, style.Dashes...) + } + path = path.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) + + r.w.SetFill(style.Stroke) + r.w.Write([]byte(" ")) + r.w.Write([]byte(path.Transform(m).ToPDF())) + r.w.Write([]byte(" f")) + */ + // return + } + if style.HasFill() && !style.HasStroke() { + r.w.SetFill(&style.Fill, bounds, tr) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } else if !style.HasFill() && style.HasStroke() { + r.w.SetStroke(&stk) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" s")) + } else { + r.w.Write([]byte(" S")) + } + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } else if style.HasFill() && style.HasStroke() { + // sameAlpha := style.Fill.IsColor() && style.Stroke.IsColor() && style.Fill.Color.A == style.Stroke.Color.A + // todo: + sameAlpha := true + if sameAlpha { + r.w.SetFill(&style.Fill, bounds, tr) + r.w.SetStroke(&style.Stroke) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" b")) + } else { + r.w.Write([]byte(" B")) + } + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } else { + r.w.SetFill(&style.Fill, bounds, tr) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + + r.w.SetStroke(&style.Stroke) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" s")) + } else { + r.w.Write([]byte(" S")) + } + if style.Fill.Rule == ppath.EvenOdd { + r.w.Write([]byte("*")) + } + } + } +} + // SetFill sets the fill style values where different from current. // The bounds and matrix are required for gradient fills: pass identity // if not available. @@ -215,7 +337,8 @@ func (w *pdfPage) gradientPattern(gr gradient.Gradient, bounds math32.Box2, m ma // fbox := sc.GetPathExtent() // lastRenderBBox := image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, // Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} - gr.Update(1, bounds, m) + cum := w.Cumulative().Mul(m) + gr.Update(1, bounds, cum) // TODO: support patterns/gradients with alpha channel shading := pdfDict{ "ColorSpace": pdfName("DeviceRGB"), @@ -228,8 +351,12 @@ func (w *pdfPage) gradientPattern(gr gradient.Gradient, bounds math32.Box2, m ma shading["Function"] = patternStopsFunction(g.Stops) shading["Extend"] = pdfArray{true, true} case *gradient.Radial: + c, f, rad := g.TransformedCoords() + // r := 0.5 * (math32.Abs(rad.X) + math32.Abs(rad.Y)) + r := max(math32.Abs(rad.X), math32.Abs(rad.Y)) + // fmt.Println("c:", c, "ctr:", g.Center) shading["ShadingType"] = 3 - shading["Coords"] = pdfArray{g.Center.X, g.Center.Y, g.Radius.X, g.Focal.X, g.Focal.Y, g.Radius.Y} + shading["Coords"] = pdfArray{f.X, f.Y, 0, c.X, c.Y, r} shading["Function"] = patternStopsFunction(g.Stops) shading["Extend"] = pdfArray{true, true} } diff --git a/paint/pdf/pdf.go b/paint/pdf/pdf.go index da2099f805..50528fcc71 100644 --- a/paint/pdf/pdf.go +++ b/paint/pdf/pdf.go @@ -12,12 +12,16 @@ import ( "io" "cogentcore.org/core/math32" - "cogentcore.org/core/paint/ppath" - "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "cogentcore.org/core/text/rich" ) +// todo: +// * needs to include the basic page xform which we neeed to install on pdf rendering stack. +// if passed bounds are empty, use path bounds. +// - compute cumulative from stack, don't pass -- only pass local +// - add a convenience method with empty bounds and ID transform, for text cases? + // UseStandardFonts sets the [rich.Settings] default fonts to the // corresponding PDF defaults, so that text layout works correctly // for the PDF rendering. The current settings are returned, @@ -165,128 +169,6 @@ func (r *PDF) Cumulative() math32.Matrix2 { return r.w.Cumulative() } -// Path renders a path to the canvas using a style and an -// individual and cumulative transformation matrix (needed for fill) -func (r *PDF) Path(path ppath.Path, style *styles.Paint, bounds math32.Box2, tr, cum math32.Matrix2) { - // PDFs don't support the arcs joiner, miter joiner (not clipped), - // or miter joiner (clipped) with non-bevel fallback - strokeUnsupported := false - if style.Stroke.Join == ppath.JoinArcs { - strokeUnsupported = true - } else if style.Stroke.Join == ppath.JoinMiter { - if style.Stroke.MiterLimit == 0 { - strokeUnsupported = true - } - // } else if _, ok := miter.GapJoiner.(canvas.BevelJoiner); !ok { - // strokeUnsupported = true - // } - } - scale := math32.Sqrt(math32.Abs(tr.Det())) - stk := style.Stroke - stk.Width.Dots *= scale - stk.DashOffset, stk.Dashes = ppath.ScaleDash(scale, stk.DashOffset, stk.Dashes) - - // PDFs don't support connecting first and last dashes if path is closed, - // so we move the start of the path if this is the case - // TODO: closing dashes - //if style.DashesClose { - // strokeUnsupported = true - //} - - closed := false - data := path.Clone().Transform(tr).ToPDF() - if 1 < len(data) && data[len(data)-1] == 'h' { - data = data[:len(data)-2] - closed = true - } - - if style.HasStroke() && strokeUnsupported { - // todo: handle with optional inclusion of stroke function as _ import - /* // style.HasStroke() && strokeUnsupported - if style.HasFill() { - r.w.SetFill(style.Fill) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - r.w.Write([]byte(" f")) - if style.Fill.Rule == canvas.EvenOdd { - r.w.Write([]byte("*")) - } - } - - // stroke settings unsupported by PDF, draw stroke explicitly - if style.IsDashed() { - path = path.Dash(style.DashOffset, style.Dashes...) - } - path = path.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) - - r.w.SetFill(style.Stroke) - r.w.Write([]byte(" ")) - r.w.Write([]byte(path.Transform(m).ToPDF())) - r.w.Write([]byte(" f")) - */ - // return - } - if style.HasFill() && !style.HasStroke() { - r.w.SetFill(&style.Fill, bounds, cum) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - r.w.Write([]byte(" f")) - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } - } else if !style.HasFill() && style.HasStroke() { - r.w.SetStroke(&stk) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - if closed { - r.w.Write([]byte(" s")) - } else { - r.w.Write([]byte(" S")) - } - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } - } else if style.HasFill() && style.HasStroke() { - // sameAlpha := style.Fill.IsColor() && style.Stroke.IsColor() && style.Fill.Color.A == style.Stroke.Color.A - // todo: - sameAlpha := true - if sameAlpha { - r.w.SetFill(&style.Fill, bounds, cum) - r.w.SetStroke(&style.Stroke) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - if closed { - r.w.Write([]byte(" b")) - } else { - r.w.Write([]byte(" B")) - } - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } - } else { - r.w.SetFill(&style.Fill, bounds, cum) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - r.w.Write([]byte(" f")) - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } - - r.w.SetStroke(&style.Stroke) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - if closed { - r.w.Write([]byte(" s")) - } else { - r.w.Write([]byte(" S")) - } - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } - } - } -} - // Image renders an image to the canvas using a transformation matrix. func (r *PDF) Image(img image.Image, m math32.Matrix2) { r.w.DrawImage(img, m) diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 20fecbd537..df6ae9ee51 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -51,7 +51,7 @@ func TestPath(t *testing.T) { sty.ToDots() tr := math32.Translate2D(10, 20) - pd.Path(*p, sty, math32.Box2{}, tr, tr) + pd.Path(*p, sty, math32.Box2{}, tr) }) } @@ -69,8 +69,7 @@ func TestGradientLinear(t *testing.T) { sty.ToDots() tr := math32.Translate2D(10, 20) - cum := pd.Cumulative() - pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr, cum.Mul(tr)) + pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr) pd.PopStack() }) } @@ -90,7 +89,7 @@ func TestGradientRadial(t *testing.T) { sty.ToDots() tr := math32.Translate2D(10, 20) - pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr, tr) + pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr) }) } diff --git a/paint/pdf/text.go b/paint/pdf/text.go index e6a7ea4310..3604583593 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -135,7 +135,7 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, psty := *style psty.Stroke.Color = run.StrokeColor psty.Fill.Color = fill - r.Path(*run.Math.Path, &psty, math32.B2FromFixed(run.Bounds()), math32.Identity2(), math32.Identity2()) + r.Path(*run.Math.Path, &psty, math32.B2FromFixed(run.Bounds()), math32.Identity2()) r.w.PopStack() return } @@ -217,7 +217,7 @@ func (r *PDF) strokeTextLine(m math32.Matrix2, sp, ep math32.Vector2, width floa sty.Stroke.Color = clr sty.Stroke.Dashes = dash p := ppath.New().Line(sp.X, sp.Y, ep.X, ep.Y) - r.Path(*p, sty, math32.Box2{}, m, m) + r.Path(*p, sty, math32.Box2{}, m) } // FillBox fills a box in the given color. @@ -227,7 +227,7 @@ func (r *PDF) FillBox(m math32.Matrix2, bb math32.Box2, clr image.Image) { sty.Fill.Color = clr sz := bb.Size() p := ppath.New().Rectangle(bb.Min.X, bb.Min.Y, sz.X, sz.Y) - r.Path(*p, sty, bb, m, m) + r.Path(*p, sty, bb, m) } func (r *PDF) links(lns *shaped.Lines, m math32.Matrix2, pos math32.Vector2) { diff --git a/paint/pdf/writer.go b/paint/pdf/writer.go index 33f0f8e38a..9f54e9aade 100644 --- a/paint/pdf/writer.go +++ b/paint/pdf/writer.go @@ -493,6 +493,7 @@ func (w *pdfPage) setTopTransform() { w.SetTransform(m) } +// dec efficiently prints float values. type dec float32 func (f dec) String() string { diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index 05920da982..c4e6969861 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -130,7 +130,7 @@ func (rs *Renderer) PopStack() int { func (rs *Renderer) RenderPath(pt *render.Path) { p := pt.Path pc := &pt.Context - rs.PDF.Path(p, &pc.Style, pc.Bounds.Rect, pc.Transform, pc.Cumulative) + rs.PDF.Path(p, &pc.Style, pc.Bounds.Rect, pc.Transform) } func (rs *Renderer) PushContext(pt *render.ContextPush) { diff --git a/svg/svg_test.go b/svg/svg_test.go index 9a7475c962..7f38a5b080 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -60,7 +60,7 @@ func TestSVG(t *testing.T) { files := fsx.Filenames(filepath.Join("testdata", dir), ".svg") for _, fn := range files { - // if fn != "fig_neuron_as_detect.svg" { + // if fn != "fig_srn_time_predict.svg" { // "fig_cortex_lobes.svg" { // continue // } RunTest(t, 640, 480, dir, fn) diff --git a/svg/testdata/svg/test.svg b/svg/testdata/svg/test.svg index c0d6c71ae4..1630246cc4 100644 --- a/svg/testdata/svg/test.svg +++ b/svg/testdata/svg/test.svg @@ -1,37 +1,49 @@ + width="1280" + height="720" + viewBox="0 0 1280 720" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg"> + id="defs"> + id="linearGradient99" + x1="0" + y1="0" + x2="1" + y2="0" + gradientUnits="objectBoundingBox"> + style="stop-color:#FFFFFF;stop-opacity:1;" + offset="0" /> + style="stop-color:#0000FF;stop-opacity:1;" + offset="1" /> + id="linearGradient643" + x1="127.5" + y1="67.22998" + x2="1024.5" + y2="67.22998" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1,0,0,1,205.15338,156.77002)" + xlink:href="#linearGradient99" /> - + + + + - From 4d18c93c65b49d70d67867612d8714ea6a27e726 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 13 Oct 2025 11:53:12 +0200 Subject: [PATCH 59/99] pdf: key fixes to gradient function to handle case with non- 0,1 gradients --- paint/pdf/paint.go | 12 +++++++----- svg/svg_test.go | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/paint/pdf/paint.go b/paint/pdf/paint.go index 2cd8879a9c..107a5ff4b5 100644 --- a/paint/pdf/paint.go +++ b/paint/pdf/paint.go @@ -380,11 +380,12 @@ func (w *pdfPage) gradientPattern(gr gradient.Gradient, bounds math32.Box2, m ma } func patternStopsFunction(stops []gradient.Stop) pdfDict { + n := len(stops) if len(stops) < 2 { return pdfDict{} } - fs := []pdfDict{} + fs := pdfArray{} encode := pdfArray{} bounds := pdfArray{} if !ppath.Equal(stops[0].Pos, 0.0) { @@ -392,19 +393,20 @@ func patternStopsFunction(stops []gradient.Stop) pdfDict { encode = append(encode, 0, 1) bounds = append(bounds, stops[0].Pos) } - for i := 0; i < len(stops)-1; i++ { + for i := range n - 1 { fs = append(fs, patternStopFunction(stops[i], stops[i+1])) encode = append(encode, 0, 1) if i != 0 { bounds = append(bounds, stops[1].Pos) } } - if !ppath.Equal(stops[len(stops)-1].Pos, 1.0) { - fs = append(fs, patternStopFunction(stops[len(stops)-1], stops[len(stops)-1])) + if !ppath.Equal(stops[n-1].Pos, 1.0) { + fs = append(fs, patternStopFunction(stops[n-1], stops[n-1])) encode = append(encode, 0, 1) + bounds = append(bounds, stops[n-1].Pos) } if len(fs) == 1 { - return fs[0] + return fs[0].(pdfDict) } return pdfDict{ "FunctionType": 3, diff --git a/svg/svg_test.go b/svg/svg_test.go index 7f38a5b080..94caf2b017 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -60,9 +60,9 @@ func TestSVG(t *testing.T) { files := fsx.Filenames(filepath.Join("testdata", dir), ".svg") for _, fn := range files { - // if fn != "fig_srn_time_predict.svg" { // "fig_cortex_lobes.svg" { - // continue - // } + if fn != "test2.svg" { + continue + } RunTest(t, 640, 480, dir, fn) } } From 972fae6296967110b0fa458d34e7c1aaf89b41c7 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 13 Oct 2025 21:16:13 +0200 Subject: [PATCH 60/99] pdf: rendering fixes and svg io marshal fix to not output duplicate transforms in groups --- paint/pdf/paint.go | 17 ++- paint/pdf/text.go | 6 +- paint/renderers/pdfrender/pdfrender.go | 2 +- svg/io.go | 7 +- svg/svg_test.go | 8 +- svg/testdata/svg/fig_bp_compute_delta.svg | 126 ++++++++++------------ 6 files changed, 85 insertions(+), 81 deletions(-) diff --git a/paint/pdf/paint.go b/paint/pdf/paint.go index 107a5ff4b5..2e735e8a48 100644 --- a/paint/pdf/paint.go +++ b/paint/pdf/paint.go @@ -23,7 +23,7 @@ import ( // Path renders a path to the canvas using a style and an // individual matrix (needed for fill) -func (r *PDF) Path(path ppath.Path, style *styles.Paint, bounds math32.Box2, tr math32.Matrix2) { +func (r *PDF) Path(path ppath.Path, style *styles.Paint, tr math32.Matrix2) { // PDFs don't support the arcs joiner, miter joiner (not clipped), // or miter joiner (clipped) with non-bevel fallback strokeUnsupported := false @@ -50,12 +50,22 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, bounds math32.Box2, tr //} closed := false - data := path.Clone().Transform(tr).ToPDF() + trpath := path.Clone().Transform(tr) + data := trpath.ToPDF() if 1 < len(data) && data[len(data)-1] == 'h' { data = data[:len(data)-2] closed = true } + var bounds math32.Box2 + if style.HasFill() { + if _, ok := style.Fill.Color.(gradient.Gradient); ok { + fbox := trpath.FastBounds() + cum := r.Cumulative() + bounds = fbox.MulMatrix2(cum) + } + } + if style.HasStroke() && strokeUnsupported { // todo: handle with optional inclusion of stroke function as _ import /* // style.HasStroke() && strokeUnsupported @@ -334,9 +344,6 @@ func (w *pdfPage) getOpacityGS(a float32) pdfName { } func (w *pdfPage) gradientPattern(gr gradient.Gradient, bounds math32.Box2, m math32.Matrix2) pdfName { - // fbox := sc.GetPathExtent() - // lastRenderBBox := image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, - // Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} cum := w.Cumulative().Mul(m) gr.Update(1, bounds, cum) // TODO: support patterns/gradients with alpha channel diff --git a/paint/pdf/text.go b/paint/pdf/text.go index 3604583593..a2fe929299 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -135,7 +135,7 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, psty := *style psty.Stroke.Color = run.StrokeColor psty.Fill.Color = fill - r.Path(*run.Math.Path, &psty, math32.B2FromFixed(run.Bounds()), math32.Identity2()) + r.Path(*run.Math.Path, &psty, math32.Identity2()) r.w.PopStack() return } @@ -217,7 +217,7 @@ func (r *PDF) strokeTextLine(m math32.Matrix2, sp, ep math32.Vector2, width floa sty.Stroke.Color = clr sty.Stroke.Dashes = dash p := ppath.New().Line(sp.X, sp.Y, ep.X, ep.Y) - r.Path(*p, sty, math32.Box2{}, m) + r.Path(*p, sty, m) } // FillBox fills a box in the given color. @@ -227,7 +227,7 @@ func (r *PDF) FillBox(m math32.Matrix2, bb math32.Box2, clr image.Image) { sty.Fill.Color = clr sz := bb.Size() p := ppath.New().Rectangle(bb.Min.X, bb.Min.Y, sz.X, sz.Y) - r.Path(*p, sty, bb, m) + r.Path(*p, sty, m) } func (r *PDF) links(lns *shaped.Lines, m math32.Matrix2, pos math32.Vector2) { diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index c4e6969861..a029b83e0d 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -130,7 +130,7 @@ func (rs *Renderer) PopStack() int { func (rs *Renderer) RenderPath(pt *render.Path) { p := pt.Path pc := &pt.Context - rs.PDF.Path(p, &pc.Style, pc.Bounds.Rect, pc.Transform) + rs.PDF.Path(p, &pc.Style, pc.Transform) } func (rs *Renderer) PushContext(pt *render.ContextPush) { diff --git a/svg/io.go b/svg/io.go index 54e4a28234..f4bbb2bb10 100644 --- a/svg/io.go +++ b/svg/io.go @@ -18,6 +18,7 @@ import ( "io/fs" "log" "os" + "slices" "strings" "cogentcore.org/core/base/iox/imagex" @@ -974,8 +975,12 @@ func MarshalXML(n tree.Node, enc *XMLEncoder, setName string) string { for k, v := range properties { sv := reflectx.ToString(v) switch k { - case "opacity", "transform": + case "opacity": XMLAddAttr(&se.Attr, k, sv) + case "transform": + if slices.IndexFunc(se.Attr, func(a xml.Attr) bool { return a.Name.Local == k }) < 0 { + XMLAddAttr(&se.Attr, k, sv) + } case "groupmode": XMLAddAttr(&se.Attr, "inkscape:groupmode", sv) if st, has := properties["style"]; has { diff --git a/svg/svg_test.go b/svg/svg_test.go index 94caf2b017..4d3956a5be 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -59,10 +59,12 @@ func TestSVG(t *testing.T) { dir := "svg" files := fsx.Filenames(filepath.Join("testdata", dir), ".svg") + // PDF currently failing: TestShapes4, 6, fig_bp_compute_delta.svg + // rect_trans.svg for _, fn := range files { - if fn != "test2.svg" { - continue - } + // if fn != "test2.svg" { + // continue + // } RunTest(t, 640, 480, dir, fn) } } diff --git a/svg/testdata/svg/fig_bp_compute_delta.svg b/svg/testdata/svg/fig_bp_compute_delta.svg index 4e0bb1eebd..6dc1a7b295 100644 --- a/svg/testdata/svg/fig_bp_compute_delta.svg +++ b/svg/testdata/svg/fig_bp_compute_delta.svg @@ -8,23 +8,37 @@ xmlns="http://www.w3.org/2000/svg"> + + + + @@ -34,20 +48,6 @@ transform="scale(0.4) rotate(180) translate(10,0)" d="M0 0L5 -5L-12.5 0L5 5z" /> - - - - @@ -239,10 +239,10 @@ + style="inkscape:zoom:1;inkscape:document-units:px;inkscape:current-layer:layer1;inkscape:cx:0;inkscape:cy:0;"> + style="spacingx:32;spacingy:32;type:xygrid;units:px;" /> @@ -678,94 +673,89 @@ @@ -782,7 +772,7 @@ Date: Mon, 13 Oct 2025 21:55:02 +0200 Subject: [PATCH 61/99] pdf: fix for bp_delta case: always push the transform.. --- paint/pdf/pdf_test.go | 6 +++--- paint/renderers/pdfrender/pdfrender.go | 6 +----- svg/svg_test.go | 4 ++-- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index df6ae9ee51..41b15b4761 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -51,7 +51,7 @@ func TestPath(t *testing.T) { sty.ToDots() tr := math32.Translate2D(10, 20) - pd.Path(*p, sty, math32.Box2{}, tr) + pd.Path(*p, sty, tr) }) } @@ -69,7 +69,7 @@ func TestGradientLinear(t *testing.T) { sty.ToDots() tr := math32.Translate2D(10, 20) - pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr) + pd.Path(*p, sty, tr) pd.PopStack() }) } @@ -89,7 +89,7 @@ func TestGradientRadial(t *testing.T) { sty.ToDots() tr := math32.Translate2D(10, 20) - pd.Path(*p, sty, math32.B2(0, 0, 30, 20), tr) + pd.Path(*p, sty, tr) }) } diff --git a/paint/renderers/pdfrender/pdfrender.go b/paint/renderers/pdfrender/pdfrender.go index a029b83e0d..eefd1ecd17 100644 --- a/paint/renderers/pdfrender/pdfrender.go +++ b/paint/renderers/pdfrender/pdfrender.go @@ -112,11 +112,7 @@ func (rs *Renderer) RenderPage(r render.Render) { func (rs *Renderer) PushTransform(m math32.Matrix2) int { cg := rs.gStack.Peek() g := cg + 1 - if m.IsIdentity() { - rs.PDF.PushStack() - } else { - rs.PDF.PushTransform(m) - } + rs.PDF.PushTransform(m) rs.gStack.Push(g) return g } diff --git a/svg/svg_test.go b/svg/svg_test.go index 4d3956a5be..0d233f2dc9 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -59,10 +59,10 @@ func TestSVG(t *testing.T) { dir := "svg" files := fsx.Filenames(filepath.Join("testdata", dir), ".svg") - // PDF currently failing: TestShapes4, 6, fig_bp_compute_delta.svg + // PDF currently failing: TestShapes4, 6, rect_trans, arrows // rect_trans.svg for _, fn := range files { - // if fn != "test2.svg" { + // if fn != "fig_bp_compute_delta.svg" { // continue // } RunTest(t, 640, 480, dir, fn) From 454ea3a01cbea54f7c3f58cd24d341e5f73582a9 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 13 Oct 2025 23:17:59 +0200 Subject: [PATCH 62/99] pdf: pdf passing most svg tests -- good enough for now! --- paint/pdf/paint.go | 48 ++++++++++++---------------------------------- svg/svg_test.go | 5 ++--- 2 files changed, 14 insertions(+), 39 deletions(-) diff --git a/paint/pdf/paint.go b/paint/pdf/paint.go index 2e735e8a48..29281bacd2 100644 --- a/paint/pdf/paint.go +++ b/paint/pdf/paint.go @@ -109,46 +109,22 @@ func (r *PDF) Path(path ppath.Path, style *styles.Paint, tr math32.Matrix2) { } else { r.w.Write([]byte(" S")) } + } else if style.HasFill() && style.HasStroke() { + r.w.SetFill(&style.Fill, bounds, tr) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + r.w.Write([]byte(" f")) if style.Fill.Rule == ppath.EvenOdd { r.w.Write([]byte("*")) } - } else if style.HasFill() && style.HasStroke() { - // sameAlpha := style.Fill.IsColor() && style.Stroke.IsColor() && style.Fill.Color.A == style.Stroke.Color.A - // todo: - sameAlpha := true - if sameAlpha { - r.w.SetFill(&style.Fill, bounds, tr) - r.w.SetStroke(&style.Stroke) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - if closed { - r.w.Write([]byte(" b")) - } else { - r.w.Write([]byte(" B")) - } - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } - } else { - r.w.SetFill(&style.Fill, bounds, tr) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - r.w.Write([]byte(" f")) - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } - r.w.SetStroke(&style.Stroke) - r.w.Write([]byte(" ")) - r.w.Write([]byte(data)) - if closed { - r.w.Write([]byte(" s")) - } else { - r.w.Write([]byte(" S")) - } - if style.Fill.Rule == ppath.EvenOdd { - r.w.Write([]byte("*")) - } + r.w.SetStroke(&stk) + r.w.Write([]byte(" ")) + r.w.Write([]byte(data)) + if closed { + r.w.Write([]byte(" s")) + } else { + r.w.Write([]byte(" S")) } } } diff --git a/svg/svg_test.go b/svg/svg_test.go index 0d233f2dc9..e47f8de2c6 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -59,10 +59,9 @@ func TestSVG(t *testing.T) { dir := "svg" files := fsx.Filenames(filepath.Join("testdata", dir), ".svg") - // PDF currently failing: TestShapes4, 6, rect_trans, arrows - // rect_trans.svg + // PDF currently failing: TestShapes4, 6, for _, fn := range files { - // if fn != "fig_bp_compute_delta.svg" { + // if fn != "fig_neuron_as_detect_test.svg" { // continue // } RunTest(t, 640, 480, dir, fn) From d3a3e8427d36272fc6965ef5e02dd732b718e646 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 14 Oct 2025 08:32:22 +0200 Subject: [PATCH 63/99] pdf: svg to pdf and svg clone methods --- paint/pdf/pdf_test.go | 2 +- styles/units/context.go | 2 +- svg/svg.go | 28 + svg/testdata/svg/fig_neuron_as_detect.svg | 855 +++++++++++----------- 4 files changed, 471 insertions(+), 416 deletions(-) diff --git a/paint/pdf/pdf_test.go b/paint/pdf/pdf_test.go index 41b15b4761..0999b5d93e 100644 --- a/paint/pdf/pdf_test.go +++ b/paint/pdf/pdf_test.go @@ -80,7 +80,7 @@ func TestGradientRadial(t *testing.T) { // todo: this is a different definition than ours.. gg := gradient.NewRadial().AddStop(colors.White, 0).AddStop(colors.Red, 1) gg.Units = gradient.UserSpaceOnUse - gg.Center.Set(25, 20) + gg.Center.Set(15, 10) gg.Focal = gg.Center gg.Radius.Set(15, 5) sty.Stroke.Color = colors.Uniform(colors.Blue) diff --git a/styles/units/context.go b/styles/units/context.go index 8653688e31..8f242104c3 100644 --- a/styles/units/context.go +++ b/styles/units/context.go @@ -130,7 +130,7 @@ func (uc *Context) SetFont(em float32) { uc.FontRem = math32.Round(uc.Dp(16)) } -// ToDotsFact returns factor needed to convert given unit into raw pixels (dots in DPI) +// Dots returns factor needed to convert given unit into raw pixels (dots in DPI) func (uc *Context) Dots(un Units) float32 { if uc.DPI == 0 { // log.Printf("gi/units Context was not initialized -- falling back on defaults\n") diff --git a/svg/svg.go b/svg/svg.go index e7f3ca0026..c18d720885 100644 --- a/svg/svg.go +++ b/svg/svg.go @@ -10,9 +10,11 @@ import ( "bytes" "image" "image/color" + "os" "strings" "sync" + "cogentcore.org/core/base/errors" "cogentcore.org/core/base/iox/imagex" "cogentcore.org/core/colors" "cogentcore.org/core/math32" @@ -281,6 +283,32 @@ func (sv *SVG) SaveImageSize(fname string, width, height float32) error { return err } +// SavePDF renders the SVG to a PDF and saves it to given filename. +func (sv *SVG) SavePDF(fname string) error { + pos := sv.Geom.Pos + sv.Geom.Pos = image.Point{} + rend := sv.Render(nil).RenderDone() + + ctx := units.NewContext() + vsz := math32.FromPoint(sv.Geom.Size) + pd := paint.NewPDFRenderer(vsz, ctx) + pd.Render(rend) + err := os.WriteFile(fname, pd.Source(), 0666) + + sv.Geom.Pos = pos + return err +} + +// CloneSVG returns a copy of this SVG, with given size, +// by writing and reading the XML source. +func (sv *SVG) CloneSVG(size math32.Vector2) *SVG { + var b bytes.Buffer + errors.Log(sv.WriteXML(&b, false)) + nsv := NewSVG(size) + errors.Log(nsv.ReadXML(&b)) + return nsv +} + func (sv *SVG) FillViewport() { sty := styles.NewPaint() // has no transform pc := &paint.Painter{sv.painter.State, sty} diff --git a/svg/testdata/svg/fig_neuron_as_detect.svg b/svg/testdata/svg/fig_neuron_as_detect.svg index 2605305ace..c83e700e91 100644 --- a/svg/testdata/svg/fig_neuron_as_detect.svg +++ b/svg/testdata/svg/fig_neuron_as_detect.svg @@ -1,415 +1,442 @@ - - - image/svg+xml -Integration -Output -Inputs -Detector -Neuron -Synapses -Dendrites -Axon -Cell body(membrane potential) - \ No newline at end of file + width="353.75" + height="196.25" + viewBox="0 0 384 224" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Integration + + + Output + + + Inputs + + + Detector + + + Neuron + + + Synapses + + + Dendrites + + + Axon + + + Cell body + (membrane + potential) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d7a86f601d32c423a870f55a424b46bd7fa999b5 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 14 Oct 2025 09:54:19 +0200 Subject: [PATCH 64/99] pdf: fix scene handle layout issues introduced by earlier LayoutFrame function --- core/render.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/core/render.go b/core/render.go index 2775fb7267..f8e6d7c5c1 100644 --- a/core/render.go +++ b/core/render.go @@ -127,11 +127,12 @@ func (sc *Scene) LayoutScene() { if DebugSettings.LayoutTrace { fmt.Println("\n############################\nLayoutScene SizeUp start:", sc) } - sc.LayoutFrame(math32.FromPoint(sc.SceneGeom.Size)) + sc.layoutFrame(math32.FromPoint(sc.SceneGeom.Size)) + sc.ApplyScenePos() } -// LayoutFrame does a layout on the given Frame using given size. -func (fr *Frame) LayoutFrame(size math32.Vector2) { +// layoutFrame does the frame layout core functionality +func (fr *Frame) layoutFrame(size math32.Vector2) { fr.SizeUp() sz := &fr.Geom.Size sz.Alloc.Total = size @@ -161,6 +162,11 @@ func (fr *Frame) LayoutFrame(size math32.Vector2) { if DebugSettings.LayoutTrace { fmt.Println("\n############################\nScenePos start:", fr) } +} + +// layoutFrame does a layout on the given Frame using given size. +func (fr *Frame) LayoutFrame(size math32.Vector2) { + fr.layoutFrame(size) fr.ApplyScenePos() } From 2744144215b7973bab0beb748697f2b61b70ce6e Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 14 Oct 2025 13:43:01 +0200 Subject: [PATCH 65/99] pdf: finally fix the svg testdata directory structure so pdf and png are outer-level, not under svg --- svg/svg_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/svg/svg_test.go b/svg/svg_test.go index e47f8de2c6..ea86fc50cb 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -39,16 +39,16 @@ func RunTest(t *testing.T, width, height int, dir, fname string) { rd := paint.NewImageRenderer(size) img := rd.Render(rend).Image() bnm := strings.TrimSuffix(fname, ".svg") - imfn := filepath.Join(dir, "png", bnm) + imfn := filepath.Join("png", dir, bnm) // fmt.Println(svfn, imfn) imagex.Assert(t, img, imfn) - pddir := filepath.Join("testdata", dir, "pdf") - pdfn := filepath.Join(pddir, bnm+".pdf") + pddir := filepath.Join("testdata", "pdf") + pdfn := filepath.Join(pddir, dir, bnm+".pdf") ctx := units.NewContext() pd := paint.NewPDFRenderer(size, ctx) pd.Render(rend) - os.MkdirAll(pddir, 0777) + os.MkdirAll(filepath.Join(pddir, dir), 0777) err = os.WriteFile(pdfn, pd.Source(), 0666) assert.NoError(t, err) @@ -86,7 +86,7 @@ func TestViewBox(t *testing.T) { img := sv.RenderImage() fnm := fmt.Sprintf("%s_%s", fpre, ts) - imfn := filepath.Join("svg", "png", "viewbox", fnm) + imfn := filepath.Join("png", "viewbox", fnm) // fmt.Println(imfn) imagex.Assert(t, img, imfn) } @@ -156,7 +156,7 @@ func TestEmoji(t *testing.T) { } func TestFontEmoji(t *testing.T) { - t.Skip("special-case testing -- requires noto-emoji file") + // t.Skip("special-case testing -- requires noto-emoji file") // dir := filepath.Join("testdata", "noto-emoji") os.MkdirAll("testdata/font-emoji-src", 0777) fname := "/Library/Fonts/NotoColorEmoji-Regular.ttf" @@ -189,7 +189,7 @@ func TestFontEmoji(t *testing.T) { err := sv.ReadXML(b) assert.NoError(t, err) img := sv.RenderImage() - imfn := filepath.Join("svg", "png", "font-emoji", strings.TrimSuffix(fn, ".svg")) + imfn := filepath.Join("png", "font-emoji", strings.TrimSuffix(fn, ".svg")) imagex.Assert(t, img, imfn) // sv.SaveXML(sfn) // os.WriteFile(sfn, gd.Source, 0666) From 3ccc95b33e666c7d13be6949b8acb6db65c945a5 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 14 Oct 2025 15:12:48 +0200 Subject: [PATCH 66/99] pdf: convenience RenderToPDF func --- paint/state.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/paint/state.go b/paint/state.go index f97d6575e9..513dcc62c6 100644 --- a/paint/state.go +++ b/paint/state.go @@ -52,6 +52,14 @@ func RenderToSVG(pc *Painter) []byte { return rd.Render(pc.RenderDone()).Source() } +// RenderToPDF is a convenience function that renders the current +// accumulated painter actions to a PDF document using a [NewPDFRenderer] +func RenderToPDF(pc *Painter) []byte { + p := pc.RenderDone() + rd := NewPDFRenderer(pc.Size, &pc.Context().Style.UnitContext) + return rd.Render(p).Source() +} + // The State holds all the current rendering state information used // while painting. The [Paint] embeds a pointer to this. type State struct { From a81b0793eb81ffc7f6883bd91458f1302fd20445 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 14 Oct 2025 19:30:45 +0200 Subject: [PATCH 67/99] pdf: minor cleanup of addlink --- paint/pdf/links.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/paint/pdf/links.go b/paint/pdf/links.go index 74ee91ee4d..2a877bccfc 100644 --- a/paint/pdf/links.go +++ b/paint/pdf/links.go @@ -30,20 +30,16 @@ func (w *pdfPage) AddAnchor(name string, pos math32.Vector2) { func (w *pdfPage) AddLink(uri string, rect math32.Box2) { ms := math32.Scale2D(w.pdf.globalScale, w.pdf.globalScale) rect = rect.MulMatrix2(ms) - isLocal := false - if uri[0] == '#' { // local anchor actions - uri = uri[1:] - isLocal = true - } annot := pdfDict{ "Type": pdfName("Annot"), "Subtype": pdfName("Link"), "Border": pdfArray{0, 0, 0}, "Rect": pdfArray{rect.Min.X, w.height - rect.Max.Y, rect.Max.X, w.height - rect.Min.Y}, } - if isLocal { - annot["Dest"] = uri + if len(uri) > 0 && uri[0] == '#' { // local anchor actions + annot["Dest"] = uri[1:] } else { + annot["Contents"] = uri annot["A"] = pdfDict{ "S": pdfName("URI"), pdfName("URI"): uri, From bcc3cac9905962028b4fa14faf695fe9ddedba86 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 15 Oct 2025 09:24:18 +0200 Subject: [PATCH 68/99] pdf: add OnlineURL for docs --- docs/docs.go | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs.go b/docs/docs.go index 45ff203216..e421b55d22 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -59,6 +59,7 @@ func main() { b := core.NewBody("Cogent Core Docs") ct := content.NewContent(b).SetContent(econtent) ctx := ct.Context + content.OfflineURL = "https://cogentcore.org/core" ctx.AddWikilinkHandler(htmlcore.GoDocWikilink("doc", "cogentcore.org/core")) b.AddTopBar(func(bar *core.Frame) { tb := core.NewToolbar(bar) From 63dcaa54186933df9c9fed28f2baccb280060439 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 21 Oct 2025 09:54:33 +0200 Subject: [PATCH 69/99] pdf: don't run TestFontEmoji --- svg/svg_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/svg/svg_test.go b/svg/svg_test.go index ea86fc50cb..5c0776794e 100644 --- a/svg/svg_test.go +++ b/svg/svg_test.go @@ -156,7 +156,7 @@ func TestEmoji(t *testing.T) { } func TestFontEmoji(t *testing.T) { - // t.Skip("special-case testing -- requires noto-emoji file") + t.Skip("special-case testing -- requires noto-emoji file") // dir := filepath.Join("testdata", "noto-emoji") os.MkdirAll("testdata/font-emoji-src", 0777) fname := "/Library/Fonts/NotoColorEmoji-Regular.ttf" From c8d48bf1d549654025a30caf9ecb86df8b7fab51 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 4 Nov 2025 16:18:31 +0100 Subject: [PATCH 70/99] pdf: paginate ensures font is black and use light mode so links are the correct blue color. --- text/paginate/pdf.go | 6 ++++++ text/paginate/runners.go | 30 ++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/text/paginate/pdf.go b/text/paginate/pdf.go index 167b4b0c58..c051a614ef 100644 --- a/text/paginate/pdf.go +++ b/text/paginate/pdf.go @@ -7,6 +7,8 @@ package paginate import ( "io" + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/matcolor" "cogentcore.org/core/core" "cogentcore.org/core/paint" "cogentcore.org/core/paint/pdf" @@ -27,6 +29,8 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { if len(ins) == 0 { return } + cmode := matcolor.SchemeIsDark + colors.SetScheme(false) cset := pdf.UseStandardFonts() p := pager{opts: &opts, ins: ins} @@ -67,6 +71,7 @@ func PDF(w io.Writer, opts Options, ins ...core.Widget) { sc.DeleteChildren() } pdr.EndRender() + colors.SetScheme(cmode) pdf.RestorePreviousFonts(cset) } @@ -108,6 +113,7 @@ func (p *pager) assemble() { s.Text.LineHeight = printer.Settings.LineHeight } s.Font.Size.Value *= fsc + s.Color = colors.Uniform(colors.Black) // in case dark mode if p.opts.TextStyler != nil { p.opts.TextStyler(tx) } diff --git a/text/paginate/runners.go b/text/paginate/runners.go index ed4fb8e840..10981d7180 100644 --- a/text/paginate/runners.go +++ b/text/paginate/runners.go @@ -8,6 +8,7 @@ import ( "strconv" "cogentcore.org/core/base/errors" + "cogentcore.org/core/colors" "cogentcore.org/core/core" "cogentcore.org/core/styles" "cogentcore.org/core/text/printer" @@ -15,6 +16,13 @@ import ( "cogentcore.org/core/text/text" ) +// TextStyler does standard text styling for printout: +// FontFamily and Black Color (e.g., in case user is in Dark mode). +func TextStyler(s *styles.Style) { + s.Font.Family = printer.Settings.FontFamily + s.Color = colors.Uniform(colors.Black) +} + // CenteredPageNumber generates a page number cenetered in the frame // with a 1.5em space above it. func CenteredPageNumber(frame *core.Frame, opts *Options, pageNo int) { @@ -28,7 +36,9 @@ func CenteredPageNumber(frame *core.Frame, opts *Options, pageNo int) { s.Grow.Set(1, 0) s.Justify.Content = styles.Center }) - core.NewText(fr).SetText(strconv.Itoa(pageNo)) + core.NewText(fr).SetText(strconv.Itoa(pageNo)).Styler(func(s *styles.Style) { + TextStyler(s) + }) } // NoFirst excludes the first page for any runner @@ -51,15 +61,15 @@ func HeaderLeftPageNumber(header string) func(frame *core.Frame, opts *Options, s.Grow.Set(1, 0) }) core.NewText(fr).SetText(header).Styler(func(s *styles.Style) { + TextStyler(s) s.SetTextWrap(false) - s.Font.Family = printer.Settings.FontFamily s.Font.Slant = rich.Italic s.Font.Size = printer.Settings.FontSize }) core.NewStretch(fr) core.NewText(fr).SetText(strconv.Itoa(pageNo)).Styler(func(s *styles.Style) { + TextStyler(s) s.SetTextWrap(false) - s.Font.Family = printer.Settings.FontFamily s.Font.Size = printer.Settings.FontSize }) core.NewSpace(frame).Styler(func(s *styles.Style) { // space after @@ -86,7 +96,7 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun s.Min.Y.Em(.1) }) core.NewText(fr).SetText(title).Styler(func(s *styles.Style) { - s.Font.Family = printer.Settings.FontFamily + TextStyler(s) s.Font.Size = printer.Settings.FontSize s.Font.Size.Value *= 16.0 / 11 s.Text.Align = text.Center @@ -94,7 +104,7 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun if authors != "" { core.NewText(fr).SetText(authors).Styler(func(s *styles.Style) { - s.Font.Family = printer.Settings.FontFamily + TextStyler(s) s.Font.Size = printer.Settings.FontSize s.Font.Size.Value *= 12.0 / 11 s.Text.Align = text.Center @@ -103,7 +113,7 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun if affiliations != "" { core.NewText(fr).SetText(affiliations).Styler(func(s *styles.Style) { - s.Font.Family = printer.Settings.FontFamily + TextStyler(s) s.Font.Size = printer.Settings.FontSize s.Text.Align = text.Center s.Text.LineHeight = 1.1 @@ -113,7 +123,7 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun if date != "" { core.NewText(fr).SetText(date).Styler(func(s *styles.Style) { - s.Font.Family = printer.Settings.FontFamily + TextStyler(s) s.Font.Size = printer.Settings.FontSize s.Text.Align = text.Center }) @@ -121,7 +131,7 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun if url != "" { core.NewText(fr).SetText(url).Styler(func(s *styles.Style) { - s.Font.Family = printer.Settings.FontFamily + TextStyler(s) s.Font.Size = printer.Settings.FontSize s.Text.Align = text.Center }) @@ -129,14 +139,14 @@ func CenteredTitle(title, authors, affiliations, url, date, abstract string) fun if abstract != "" { core.NewText(fr).SetText("Abstract:").Styler(func(s *styles.Style) { - s.Font.Family = printer.Settings.FontFamily + TextStyler(s) s.Font.Size = printer.Settings.FontSize s.Font.Size.Value *= 12.0 / 11 s.Font.Weight = rich.Bold s.Align.Self = styles.Start }) core.NewText(fr).SetText(abstract).Styler(func(s *styles.Style) { - s.Font.Family = printer.Settings.FontFamily + TextStyler(s) s.Font.Size = printer.Settings.FontSize s.Text.LineHeight = printer.Settings.LineHeight s.Align.Self = styles.Start From 80f098a17456f01ac8f1a12a846a7ae80f7ced9c Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 20 Nov 2025 10:07:53 +0100 Subject: [PATCH 71/99] pdf: tables have less space between rows -- standard formatting for tables in print. --- content/handlers.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/content/handlers.go b/content/handlers.go index 0d83d010cc..a08213e405 100644 --- a/content/handlers.go +++ b/content/handlers.go @@ -154,7 +154,11 @@ func (ct *Content) widgetHandler(w core.Widget) { case *core.Text: hdr := len(tag) > 0 && tag[0] == 'h' x.Styler(func(s *styles.Style) { - s.Margin.SetVertical(units.Em(core.ConstantSpacing(0.25))) + if tag == "td" { + s.Margin.SetVertical(units.Em(0)) // use gap + } else { + s.Margin.SetVertical(units.Em(core.ConstantSpacing(0.25))) + } s.Font.Size.Value *= core.AppearanceSettings.DocsFontSize / 100 s.Max.X.In(8) // big enough to not constrain PDF render if hdr { @@ -179,18 +183,17 @@ func (ct *Content) widgetHandler(w core.Widget) { case *core.Frame: switch tag { case "table": + if id != "" { + lbl := ct.currentPage.SpecialLabel(id) + cp := "" + lbl + ":" + if title != "" { + cp += " " + title + } + ct.moveToBlockFrame(w, id, cp, true) + } x.Styler(func(s *styles.Style) { s.Align.Self = styles.Center }) - if id == "" { - break - } - lbl := ct.currentPage.SpecialLabel(id) - cp := "" + lbl + ":" - if title != "" { - cp += " " + title - } - ct.moveToBlockFrame(w, id, cp, true) } } } From 59071c03153e23473d4257ba3bb2cdd947324df0 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sat, 22 Nov 2025 08:44:49 +0100 Subject: [PATCH 72/99] pdf: change matcolor scheme to use 100 and 0 for Background: the almost-white-but-kinda-faded-and-weird background is just not good, and I don't care what the material 3 specs say. if we need to, we can have an option to enable it, but this has always bugged me and I think this looks 100% better. --- colors/matcolor/scheme.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/colors/matcolor/scheme.go b/colors/matcolor/scheme.go index 61ad40b2db..d89a2670f6 100644 --- a/colors/matcolor/scheme.go +++ b/colors/matcolor/scheme.go @@ -118,7 +118,7 @@ func NewLightScheme(p *Palette) Scheme { SurfaceDim: p.Neutral.AbsToneUniform(87), Surface: p.Neutral.AbsToneUniform(98), - SurfaceBright: p.Neutral.AbsToneUniform(98), + SurfaceBright: p.Neutral.AbsToneUniform(99), SurfaceContainerLowest: p.Neutral.AbsToneUniform(100), SurfaceContainerLow: p.Neutral.AbsToneUniform(96), @@ -134,7 +134,7 @@ func NewLightScheme(p *Palette) Scheme { InverseOnSurface: p.Neutral.AbsToneUniform(95), InversePrimary: p.Primary.AbsToneUniform(80), - Background: p.Neutral.AbsToneUniform(98), + Background: p.Neutral.AbsToneUniform(100), OnBackground: p.Neutral.AbsToneUniform(10), Outline: p.NeutralVariant.AbsToneUniform(50), @@ -182,7 +182,7 @@ func NewDarkScheme(p *Palette) Scheme { InverseOnSurface: p.Neutral.AbsToneUniform(20), InversePrimary: p.Primary.AbsToneUniform(40), - Background: p.Neutral.AbsToneUniform(6), + Background: p.Neutral.AbsToneUniform(0), OnBackground: p.Neutral.AbsToneUniform(90), Outline: p.NeutralVariant.AbsToneUniform(60), From 5b9ee06409b0d337e364b9cdc2cb2fad08efe274 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sat, 22 Nov 2025 09:05:12 +0100 Subject: [PATCH 73/99] pdf: matcolor scheme also needs to brighten the surface values a notch to match brighter background. --- colors/matcolor/scheme.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/colors/matcolor/scheme.go b/colors/matcolor/scheme.go index d89a2670f6..30f0c2905a 100644 --- a/colors/matcolor/scheme.go +++ b/colors/matcolor/scheme.go @@ -117,14 +117,14 @@ func NewLightScheme(p *Palette) Scheme { Custom: map[string]Accent{}, SurfaceDim: p.Neutral.AbsToneUniform(87), - Surface: p.Neutral.AbsToneUniform(98), + Surface: p.Neutral.AbsToneUniform(99), SurfaceBright: p.Neutral.AbsToneUniform(99), SurfaceContainerLowest: p.Neutral.AbsToneUniform(100), - SurfaceContainerLow: p.Neutral.AbsToneUniform(96), - SurfaceContainer: p.Neutral.AbsToneUniform(94), - SurfaceContainerHigh: p.Neutral.AbsToneUniform(92), - SurfaceContainerHighest: p.Neutral.AbsToneUniform(90), + SurfaceContainerLow: p.Neutral.AbsToneUniform(98), + SurfaceContainer: p.Neutral.AbsToneUniform(96), + SurfaceContainerHigh: p.Neutral.AbsToneUniform(94), + SurfaceContainerHighest: p.Neutral.AbsToneUniform(92), SurfaceVariant: p.NeutralVariant.AbsToneUniform(90), OnSurface: p.NeutralVariant.AbsToneUniform(10), From 2bd13fd91c9640d6635361fcb161f44c6ce1a7d2 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 30 Nov 2025 21:29:47 +0100 Subject: [PATCH 74/99] pdf: minor math32 vector reordering and doc --- gpu/doc.go | 2 +- math32/vector2.go | 360 +++++++++++++++++++++++---------------------- math32/vector2i.go | 4 +- 3 files changed, 187 insertions(+), 179 deletions(-) diff --git a/gpu/doc.go b/gpu/doc.go index 8097babee6..fcb240abcc 100644 --- a/gpu/doc.go +++ b/gpu/doc.go @@ -5,7 +5,7 @@ /* Package gpu implements a convenient interface to the WebGPU graphics and compute framework, in Go, using the -... bindings +[github.com/cogentcore/webgpu] bindings. The Cogent Core GUI framework runs on top of this. */ diff --git a/math32/vector2.go b/math32/vector2.go index 8dc6c84d70..3fdbe1d138 100644 --- a/math32/vector2.go +++ b/math32/vector2.go @@ -72,128 +72,11 @@ func (v *Vector2) SetFromVector2i(vi Vector2i) { v.Y = float32(vi.Y) } -// SetDim sets the given vector component value by its dimension index. -func (v *Vector2) SetDim(dim Dims, value float32) { - switch dim { - case X: - v.X = value - case Y: - v.Y = value - default: - panic("dim is out of range") - } -} - -// Dim returns the given vector component. -func (v Vector2) Dim(dim Dims) float32 { - switch dim { - case X: - return v.X - case Y: - return v.Y - default: - panic("dim is out of range") - } -} - -// SetPointDim sets the given dimension of the given [image.Point] to the given value. -func SetPointDim(pt *image.Point, dim Dims, value int) { - switch dim { - case X: - pt.X = value - case Y: - pt.Y = value - default: - panic("dim is out of range") - } -} - -// PointDim returns the given dimension of the given [image.Point]. -func PointDim(pt image.Point, dim Dims) int { - switch dim { - case X: - return pt.X - case Y: - return pt.Y - default: - panic("dim is out of range") - } -} - -func (a Vector2) String() string { - return fmt.Sprintf("(%v, %v)", a.X, a.Y) -} - -// SetPoint sets the vector from the given [image.Point]. -func (a *Vector2) SetPoint(pt image.Point) { - a.X = float32(pt.X) - a.Y = float32(pt.Y) -} - -// SetFixed sets the vector from the given [fixed.Point26_6]. -func (a *Vector2) SetFixed(pt fixed.Point26_6) { - a.X = FromFixed(pt.X) - a.Y = FromFixed(pt.Y) -} - -// ToPoint returns the vector as an [image.Point]. -func (a Vector2) ToPoint() image.Point { - return image.Point{int(a.X), int(a.Y)} -} - -// ToPointFloor returns the vector as an [image.Point] with all values [Floor]ed. -func (a Vector2) ToPointFloor() image.Point { - return image.Point{int(Floor(a.X)), int(Floor(a.Y))} -} - -// ToPointCeil returns the vector as an [image.Point] with all values [Ceil]ed. -func (a Vector2) ToPointCeil() image.Point { - return image.Point{int(Ceil(a.X)), int(Ceil(a.Y))} -} - -// ToPointRound returns the vector as an [image.Point] with all values [Round]ed. -func (a Vector2) ToPointRound() image.Point { - return image.Point{int(Round(a.X)), int(Round(a.Y))} -} - -// ToFixed returns the vector as a [fixed.Point26_6]. -func (a Vector2) ToFixed() fixed.Point26_6 { - return ToFixedPoint(a.X, a.Y) -} - -// RectFromPosSizeMax returns an [image.Rectangle] from the floor of pos -// and ceil of size. -func RectFromPosSizeMax(pos, size Vector2) image.Rectangle { - tp := pos.ToPointFloor() - ts := size.ToPointCeil() - return image.Rect(tp.X, tp.Y, tp.X+ts.X, tp.Y+ts.Y) -} - -// RectFromPosSizeMin returns an [image.Rectangle] from the ceil of pos -// and floor of size. -func RectFromPosSizeMin(pos, size Vector2) image.Rectangle { - tp := pos.ToPointCeil() - ts := size.ToPointFloor() - return image.Rect(tp.X, tp.Y, tp.X+ts.X, tp.Y+ts.Y) -} - // SetZero sets all of the vector's components to zero. func (v *Vector2) SetZero() { v.SetScalar(0) } -// FromSlice sets this vector's components from the given slice, starting at offset. -func (v *Vector2) FromSlice(slice []float32, offset int) { - v.X = slice[offset] - v.Y = slice[offset+1] -} - -// ToSlice copies this vector's components to the given slice, starting at offset. -func (v Vector2) ToSlice(slice []float32, offset int) { - slice[offset] = v.X - slice[offset+1] = v.Y -} - // Basic math operations: // Add adds the other given vector to this one and returns the result as a new vector. @@ -336,70 +219,11 @@ func (v *Vector2) Clamp(min, max Vector2) { } } -// Floor returns this vector with [Floor] applied to each of its components. -func (v Vector2) Floor() Vector2 { - return Vec2(Floor(v.X), Floor(v.Y)) -} - -// Ceil returns this vector with [Ceil] applied to each of its components. -func (v Vector2) Ceil() Vector2 { - return Vec2(Ceil(v.X), Ceil(v.Y)) -} - -// Round returns this vector with [Round] applied to each of its components. -func (v Vector2) Round() Vector2 { - return Vec2(Round(v.X), Round(v.Y)) -} - // Negate returns the vector with each component negated. func (v Vector2) Negate() Vector2 { return Vec2(-v.X, -v.Y) } -// AddDim returns the vector with the given value added on the given dimension. -func (a Vector2) AddDim(d Dims, value float32) Vector2 { - switch d { - case X: - a.X += value - case Y: - a.Y += value - } - return a -} - -// SubDim returns the vector with the given value subtracted on the given dimension. -func (a Vector2) SubDim(d Dims, value float32) Vector2 { - switch d { - case X: - a.X -= value - case Y: - a.Y -= value - } - return a -} - -// MulDim returns the vector with the given value multiplied by on the given dimension. -func (a Vector2) MulDim(d Dims, value float32) Vector2 { - switch d { - case X: - a.X *= value - case Y: - a.Y *= value - } - return a -} - -// DivDim returns the vector with the given value divided by on the given dimension. -func (a Vector2) DivDim(d Dims, value float32) Vector2 { - switch d { - case X: - a.X /= value - case Y: - a.Y /= value - } - return a -} - // Distance, Normal: // Dot returns the dot product of this vector with the given other vector. @@ -498,3 +322,187 @@ func (v Vector2) Rot(phi float32, p0 Vector2) Vector2 { p0.Y + sinphi*(v.X-p0.X) + cosphi*(v.Y-p0.Y), } } + +func (a Vector2) String() string { + return fmt.Sprintf("(%v, %v)", a.X, a.Y) +} + +// SetDim sets the given vector component value by its dimension index. +func (v *Vector2) SetDim(dim Dims, value float32) { + switch dim { + case X: + v.X = value + case Y: + v.Y = value + default: + panic("dim is out of range") + } +} + +// Dim returns the given vector component. +func (v Vector2) Dim(dim Dims) float32 { + switch dim { + case X: + return v.X + case Y: + return v.Y + default: + panic("dim is out of range") + } +} + +// SetPointDim sets the given dimension of the given [image.Point] to the given value. +func SetPointDim(pt *image.Point, dim Dims, value int) { + switch dim { + case X: + pt.X = value + case Y: + pt.Y = value + default: + panic("dim is out of range") + } +} + +// PointDim returns the given dimension of the given [image.Point]. +func PointDim(pt image.Point, dim Dims) int { + switch dim { + case X: + return pt.X + case Y: + return pt.Y + default: + panic("dim is out of range") + } +} + +// AddDim returns the vector with the given value added on the given dimension. +func (a Vector2) AddDim(d Dims, value float32) Vector2 { + switch d { + case X: + a.X += value + case Y: + a.Y += value + default: + panic("dim is out of range") + } + return a +} + +// SubDim returns the vector with the given value subtracted on the given dimension. +func (a Vector2) SubDim(d Dims, value float32) Vector2 { + switch d { + case X: + a.X -= value + case Y: + a.Y -= value + default: + panic("dim is out of range") + } + return a +} + +// MulDim returns the vector with the given value multiplied by on the given dimension. +func (a Vector2) MulDim(d Dims, value float32) Vector2 { + switch d { + case X: + a.X *= value + case Y: + a.Y *= value + default: + panic("dim is out of range") + } + return a +} + +// DivDim returns the vector with the given value divided by on the given dimension. +func (a Vector2) DivDim(d Dims, value float32) Vector2 { + switch d { + case X: + a.X /= value + case Y: + a.Y /= value + default: + panic("dim is out of range") + } + return a +} + +// SetPoint sets the vector from the given [image.Point]. +func (a *Vector2) SetPoint(pt image.Point) { + a.X = float32(pt.X) + a.Y = float32(pt.Y) +} + +// SetFixed sets the vector from the given [fixed.Point26_6]. +func (a *Vector2) SetFixed(pt fixed.Point26_6) { + a.X = FromFixed(pt.X) + a.Y = FromFixed(pt.Y) +} + +// ToPoint returns the vector as an [image.Point]. +func (a Vector2) ToPoint() image.Point { + return image.Point{int(a.X), int(a.Y)} +} + +// ToPointFloor returns the vector as an [image.Point] with all values [Floor]ed. +func (a Vector2) ToPointFloor() image.Point { + return image.Point{int(Floor(a.X)), int(Floor(a.Y))} +} + +// ToPointCeil returns the vector as an [image.Point] with all values [Ceil]ed. +func (a Vector2) ToPointCeil() image.Point { + return image.Point{int(Ceil(a.X)), int(Ceil(a.Y))} +} + +// ToPointRound returns the vector as an [image.Point] with all values [Round]ed. +func (a Vector2) ToPointRound() image.Point { + return image.Point{int(Round(a.X)), int(Round(a.Y))} +} + +// ToFixed returns the vector as a [fixed.Point26_6]. +func (a Vector2) ToFixed() fixed.Point26_6 { + return ToFixedPoint(a.X, a.Y) +} + +// RectFromPosSizeMax returns an [image.Rectangle] from the floor of pos +// and ceil of size. +func RectFromPosSizeMax(pos, size Vector2) image.Rectangle { + tp := pos.ToPointFloor() + ts := size.ToPointCeil() + return image.Rect(tp.X, tp.Y, tp.X+ts.X, tp.Y+ts.Y) +} + +// RectFromPosSizeMin returns an [image.Rectangle] from the ceil of pos +// and floor of size. +func RectFromPosSizeMin(pos, size Vector2) image.Rectangle { + tp := pos.ToPointCeil() + ts := size.ToPointFloor() + return image.Rect(tp.X, tp.Y, tp.X+ts.X, tp.Y+ts.Y) +} + +// FromSlice sets this vector's components from the given slice, starting at offset. +func (v *Vector2) FromSlice(slice []float32, offset int) { + v.X = slice[offset] + v.Y = slice[offset+1] +} + +// ToSlice copies this vector's components to the given slice, starting at offset. +func (v Vector2) ToSlice(slice []float32, offset int) { + slice[offset] = v.X + slice[offset+1] = v.Y +} + +// Floor returns this vector with [Floor] applied to each of its components. +func (v Vector2) Floor() Vector2 { + return Vec2(Floor(v.X), Floor(v.Y)) +} + +// Ceil returns this vector with [Ceil] applied to each of its components. +func (v Vector2) Ceil() Vector2 { + return Vec2(Ceil(v.X), Ceil(v.Y)) +} + +// Round returns this vector with [Round] applied to each of its components. +func (v Vector2) Round() Vector2 { + return Vec2(Round(v.X), Round(v.Y)) +} diff --git a/math32/vector2i.go b/math32/vector2i.go index 9fa8e2cfc9..ffe60df6b9 100644 --- a/math32/vector2i.go +++ b/math32/vector2i.go @@ -17,8 +17,8 @@ type Vector2i struct { } // Vec2i returns a new [Vector2i] with the given x and y components. -func Vec2i(x, y int32) Vector2i { - return Vector2i{X: x, Y: y} +func Vec2i(x, y int) Vector2i { + return Vector2i{X: int32(x), Y: int32(y)} } // Vector2iScalar returns a new [Vector2i] with all components set to the given scalar value. From a352e4501a714c4d01ab18914ea3a05f070517f8 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 15 Dec 2025 06:57:39 +0100 Subject: [PATCH 75/99] pdf: rename the awkward MulVector2As methods to simpler MulVector and MulPoint --- colors/gradient/linear.go | 10 +++---- colors/gradient/radial.go | 12 ++++---- gpu/drawmatrix/matrix.go | 4 +-- math32/box2.go | 8 +++--- math32/box3.go | 7 +++++ math32/matrix2.go | 10 ++++--- math32/matrix2_test.go | 44 ++++++++++++++--------------- math32/matrix3.go | 11 ++++---- math32/matrix3_test.go | 26 ++++++++--------- math32/quaternion.go | 25 ++++++---------- math32/quaternion_test.go | 3 +- math32/vector3.go | 18 +++++------- paint/painter.go | 8 +++--- paint/pdf/links.go | 2 +- paint/pdf/page.go | 8 +++--- paint/ppath/transform.go | 14 ++++----- paint/renderers/rasterx/renderer.go | 8 +++--- paint/renderers/rasterx/shapes.go | 2 +- paint/renderers/rasterx/text.go | 28 +++++++++--------- svg/circle.go | 2 +- svg/ellipse.go | 4 +-- svg/image.go | 4 +-- svg/line.go | 4 +-- svg/node.go | 4 +-- svg/polyline.go | 2 +- svg/rect.go | 4 +-- svg/text.go | 4 +-- svg/zoom.go | 4 +-- 28 files changed, 138 insertions(+), 142 deletions(-) diff --git a/colors/gradient/linear.go b/colors/gradient/linear.go index 011005810f..d12b8dd937 100644 --- a/colors/gradient/linear.go +++ b/colors/gradient/linear.go @@ -65,10 +65,10 @@ func (l *Linear) Update(opacity float32, box math32.Box2, objTransform math32.Ma l.rStart = l.Box.Min.Add(sz.Mul(l.Start)) l.rEnd = l.Box.Min.Add(sz.Mul(l.End)) } else { - l.rStart = l.Transform.MulVector2AsPoint(l.Start) - l.rEnd = l.Transform.MulVector2AsPoint(l.End) - l.rStart = objTransform.MulVector2AsPoint(l.rStart) - l.rEnd = objTransform.MulVector2AsPoint(l.rEnd) + l.rStart = l.Transform.MulPoint(l.Start) + l.rEnd = l.Transform.MulPoint(l.End) + l.rStart = objTransform.MulPoint(l.rStart) + l.rEnd = objTransform.MulPoint(l.rEnd) } l.distance = l.rEnd.Sub(l.rStart) @@ -92,7 +92,7 @@ func (l *Linear) At(x, y int) color.Color { pt := math32.Vec2(float32(x)+0.5, float32(y)+0.5) if l.Units == ObjectBoundingBox { - pt = l.boxTransform.MulVector2AsPoint(pt) + pt = l.boxTransform.MulPoint(pt) } df := pt.Sub(l.rStart) pos := (l.distance.X*df.X + l.distance.Y*df.Y) / l.distanceLengthSquared diff --git a/colors/gradient/radial.go b/colors/gradient/radial.go index 86a33e7ebe..8326ad8777 100644 --- a/colors/gradient/radial.go +++ b/colors/gradient/radial.go @@ -74,8 +74,8 @@ func (r *Radial) Update(opacity float32, box math32.Box2, objTransform math32.Ma rs.SetMul(sz) } else { ct := objTransform.Mul(r.Transform) - c = ct.MulVector2AsPoint(c) - f = ct.MulVector2AsPoint(f) + c = ct.MulPoint(c) + f = ct.MulPoint(f) _, _, phi, sx, sy, _ := ct.Decompose() r.rotTrans = math32.Rotate2D(phi) rs.SetMul(math32.Vec2(sx, sy)) @@ -118,9 +118,9 @@ func (r *Radial) At(x, y int) color.Color { // pos is just distance from center scaled by radius pt := math32.Vec2(float32(x)+0.5, float32(y)+0.5) if r.Units == ObjectBoundingBox { - pt = r.boxTransform.MulVector2AsPoint(pt) + pt = r.boxTransform.MulPoint(pt) } - d := r.rotTrans.MulVector2AsVector(pt.Sub(r.rCenter)) + d := r.rotTrans.MulVector(pt.Sub(r.rCenter)) pos := math32.Sqrt(d.X*d.X/(r.rRadius.X*r.rRadius.X) + (d.Y*d.Y)/(r.rRadius.Y*r.rRadius.Y)) return r.getColor(pos) } @@ -130,9 +130,9 @@ func (r *Radial) At(x, y int) color.Color { pt := math32.Vec2(float32(x)+0.5, float32(y)+0.5) if r.Units == ObjectBoundingBox { - pt = r.boxTransform.MulVector2AsPoint(pt) + pt = r.boxTransform.MulPoint(pt) } - pt = r.rotTrans.MulVector2AsVector(pt) + pt = r.rotTrans.MulVector(pt) e := pt.Div(r.rRadius) t1, intersects := rayCircleIntersectionF(e, r.rFocal, r.rCenter, 1) diff --git a/gpu/drawmatrix/matrix.go b/gpu/drawmatrix/matrix.go index 675c2cad22..92487d8cb0 100644 --- a/gpu/drawmatrix/matrix.go +++ b/gpu/drawmatrix/matrix.go @@ -159,8 +159,8 @@ func Transform(dr image.Rectangle, sr image.Rectangle, rotDeg float32) math32.Ma dsz := math32.FromPoint(dr.Size()) rmat := math32.Rotate2D(rad) - dmnr := rmat.MulVector2AsPoint(math32.FromPoint(dr.Min)) - dmxr := rmat.MulVector2AsPoint(math32.FromPoint(dr.Max)) + dmnr := rmat.MulPoint(math32.FromPoint(dr.Min)) + dmxr := rmat.MulPoint(math32.FromPoint(dr.Max)) sx = math32.Abs(dmxr.X-dmnr.X) / float32(sr.Dx()) sy = math32.Abs(dmxr.Y-dmnr.Y) / float32(sr.Dy()) tx = dmnr.X - sx*float32(sr.Min.X) diff --git a/math32/box2.go b/math32/box2.go index b52230c042..ae1c004142 100644 --- a/math32/box2.go +++ b/math32/box2.go @@ -157,10 +157,10 @@ func (b *Box2) ExpandByBox(box Box2) { // and computes the resulting spanning Box2 of the transformed points func (b Box2) MulMatrix2(m Matrix2) Box2 { var cs [4]Vector2 - cs[0] = m.MulVector2AsPoint(Vec2(b.Min.X, b.Min.Y)) - cs[1] = m.MulVector2AsPoint(Vec2(b.Min.X, b.Max.Y)) - cs[2] = m.MulVector2AsPoint(Vec2(b.Max.X, b.Min.Y)) - cs[3] = m.MulVector2AsPoint(Vec2(b.Max.X, b.Max.Y)) + cs[0] = m.MulPoint(Vec2(b.Min.X, b.Min.Y)) + cs[1] = m.MulPoint(Vec2(b.Min.X, b.Max.Y)) + cs[2] = m.MulPoint(Vec2(b.Max.X, b.Min.Y)) + cs[3] = m.MulPoint(Vec2(b.Max.X, b.Max.Y)) nb := B2Empty() for i := 0; i < 4; i++ { diff --git a/math32/box3.go b/math32/box3.go index e989ae19a9..86092360c8 100644 --- a/math32/box3.go +++ b/math32/box3.go @@ -55,6 +55,13 @@ func (b *Box3) Set(min, max *Vector3) { } } +// SetMinMax sets this bounding box minimum and maximum coordinates +// using specific values (see also Set) +func (b *Box3) SetMinMax(min, max Vector3) { + b.Min = min + b.Max = max +} + // SetFromPoints sets this bounding box from the specified array of points. func (b *Box3) SetFromPoints(points []Vector3) { b.SetEmpty() diff --git a/math32/matrix2.go b/math32/matrix2.go index 34a7e456fa..0a5468cb8c 100644 --- a/math32/matrix2.go +++ b/math32/matrix2.go @@ -133,16 +133,18 @@ func (a *Matrix2) SetMul(b Matrix2) { *a = a.Mul(b) } -// MulVector2AsVector multiplies the Vector2 as a vector without adding translations. +// todo: rename to MulVector, MulPoint + +// MulVector multiplies the Vector2 as a vector without adding translations. // This is for directional vectors and not points. -func (a Matrix2) MulVector2AsVector(v Vector2) Vector2 { +func (a Matrix2) MulVector(v Vector2) Vector2 { tx := a.XX*v.X + a.XY*v.Y ty := a.YX*v.X + a.YY*v.Y return Vec2(tx, ty) } -// MulVector2AsPoint multiplies the Vector2 as a point, including adding translations. -func (a Matrix2) MulVector2AsPoint(v Vector2) Vector2 { +// MulPoint multiplies the Vector2 as a point, including adding translations. +func (a Matrix2) MulPoint(v Vector2) Vector2 { tx := a.XX*v.X + a.XY*v.Y + a.X0 ty := a.YX*v.X + a.YY*v.Y + a.Y0 return Vec2(tx, ty) diff --git a/math32/matrix2_test.go b/math32/matrix2_test.go index b519457e00..a78bfcb70c 100644 --- a/math32/matrix2_test.go +++ b/math32/matrix2_test.go @@ -45,24 +45,24 @@ func TestMatrix2(t *testing.T) { rot90 := DegToRad(90) rot45 := DegToRad(45) - assert.Equal(t, vx, Identity3().MulVector2AsPoint(vx)) - assert.Equal(t, vy, Identity3().MulVector2AsPoint(vy)) - assert.Equal(t, vxy, Identity3().MulVector2AsPoint(vxy)) + assert.Equal(t, vx, Identity3().MulPoint(vx)) + assert.Equal(t, vy, Identity3().MulPoint(vy)) + assert.Equal(t, vxy, Identity3().MulPoint(vxy)) - assert.Equal(t, vxy, Translate2D(1, 1).MulVector2AsPoint(v0)) + assert.Equal(t, vxy, Translate2D(1, 1).MulPoint(v0)) - assert.Equal(t, vxy.MulScalar(2), Scale2D(2, 2).MulVector2AsPoint(vxy)) + assert.Equal(t, vxy.MulScalar(2), Scale2D(2, 2).MulPoint(vxy)) - tolAssertEqualVector(t, vy, Rotate2D(rot90).MulVector2AsPoint(vx)) // left, CCW - tolAssertEqualVector(t, vx, Rotate2D(-rot90).MulVector2AsPoint(vy)) // right, CW - tolAssertEqualVector(t, vxy.Normal(), Rotate2D(rot45).MulVector2AsPoint(vx)) - tolAssertEqualVector(t, vxy.Normal(), Rotate2D(-rot45).MulVector2AsPoint(vy)) + tolAssertEqualVector(t, vy, Rotate2D(rot90).MulPoint(vx)) // left, CCW + tolAssertEqualVector(t, vx, Rotate2D(-rot90).MulPoint(vy)) // right, CW + tolAssertEqualVector(t, vxy.Normal(), Rotate2D(rot45).MulPoint(vx)) + tolAssertEqualVector(t, vxy.Normal(), Rotate2D(-rot45).MulPoint(vy)) - tolAssertEqualVector(t, vy, Rotate2D(-rot90).Inverse().MulVector2AsPoint(vx)) - tolAssertEqualVector(t, vx, Rotate2D(rot90).Inverse().MulVector2AsPoint(vy)) + tolAssertEqualVector(t, vy, Rotate2D(-rot90).Inverse().MulPoint(vx)) + tolAssertEqualVector(t, vx, Rotate2D(rot90).Inverse().MulPoint(vy)) - tolAssertEqualVector(t, vxy, Rotate2D(-rot45).Mul(Rotate2D(rot45)).MulVector2AsPoint(vxy)) - tolAssertEqualVector(t, vxy, Rotate2D(-rot45).Mul(Rotate2D(-rot45).Inverse()).MulVector2AsPoint(vxy)) + tolAssertEqualVector(t, vxy, Rotate2D(-rot45).Mul(Rotate2D(rot45)).MulPoint(vxy)) + tolAssertEqualVector(t, vxy, Rotate2D(-rot45).Mul(Rotate2D(-rot45).Inverse()).MulPoint(vxy)) tolassert.EqualTol(t, -rot90, Rotate2D(-rot90).ExtractRot(), standardTol) tolassert.EqualTol(t, -rot45, Rotate2D(-rot45).ExtractRot(), standardTol) @@ -71,7 +71,7 @@ func TestMatrix2(t *testing.T) { // 1,0 -> scale(2) = 2,0 -> rotate 90 = 0,2 -> trans 1,1 -> 1,3 // multiplication order is *reverse* of "logical" order: - tolAssertEqualVector(t, Vec2(1, 3), Translate2D(1, 1).Mul(Rotate2D(rot90)).Mul(Scale2D(2, 2)).MulVector2AsPoint(vx)) + tolAssertEqualVector(t, Vec2(1, 3), Translate2D(1, 1).Mul(Rotate2D(rot90)).Mul(Scale2D(2, 2)).MulPoint(vx)) } @@ -153,14 +153,14 @@ func TestMatrix2Canvas(t *testing.T) { p := Vector2{3, 4} rot90 := DegToRad(90) rot45 := DegToRad(45) - tolAssertEqualVector(t, Identity2().Translate(2.0, 2.0).MulVector2AsPoint(p), Vector2{5.0, 6.0}) - tolAssertEqualVector(t, Identity2().Scale(2.0, 2.0).MulVector2AsPoint(p), Vector2{6.0, 8.0}) - tolAssertEqualVector(t, Identity2().Scale(1.0, -1.0).MulVector2AsPoint(p), Vector2{3.0, -4.0}) - tolAssertEqualVector(t, Identity2().ScaleAbout(2.0, -1.0, 2.0, 2.0).MulVector2AsPoint(p), Vector2{4.0, 0.0}) - tolAssertEqualVector(t, Identity2().Shear(1.0, 0.0).MulVector2AsPoint(p), Vector2{7.0, 4.0}) - tolAssertEqualVector(t, Identity2().Rotate(rot90).MulVector2AsPoint(p), p.Rot90CCW()) - tolAssertEqualVector(t, Identity2().RotateAbout(rot90, 5.0, 5.0).MulVector2AsPoint(p), p.Rot(90.0*Pi/180.0, Vector2{5.0, 5.0})) - tolAssertEqualVector(t, Identity2().Rotate(rot90).Transpose().MulVector2AsPoint(p), p.Rot90CW()) + tolAssertEqualVector(t, Identity2().Translate(2.0, 2.0).MulPoint(p), Vector2{5.0, 6.0}) + tolAssertEqualVector(t, Identity2().Scale(2.0, 2.0).MulPoint(p), Vector2{6.0, 8.0}) + tolAssertEqualVector(t, Identity2().Scale(1.0, -1.0).MulPoint(p), Vector2{3.0, -4.0}) + tolAssertEqualVector(t, Identity2().ScaleAbout(2.0, -1.0, 2.0, 2.0).MulPoint(p), Vector2{4.0, 0.0}) + tolAssertEqualVector(t, Identity2().Shear(1.0, 0.0).MulPoint(p), Vector2{7.0, 4.0}) + tolAssertEqualVector(t, Identity2().Rotate(rot90).MulPoint(p), p.Rot90CCW()) + tolAssertEqualVector(t, Identity2().RotateAbout(rot90, 5.0, 5.0).MulPoint(p), p.Rot(90.0*Pi/180.0, Vector2{5.0, 5.0})) + tolAssertEqualVector(t, Identity2().Rotate(rot90).Transpose().MulPoint(p), p.Rot90CW()) tolAssertEqualMatrix2(t, Identity2().Scale(2.0, 4.0).Inverse(), Identity2().Scale(0.5, 0.25)) tolAssertEqualMatrix2(t, Identity2().Rotate(rot90).Inverse(), Identity2().Rotate(-rot90)) tolAssertEqualMatrix2(t, Identity2().Rotate(rot90).Scale(2.0, 1.0), Identity2().Scale(1.0, 2.0).Rotate(rot90)) diff --git a/math32/matrix3.go b/math32/matrix3.go index 00ccd0ae95..65fbbde3e6 100644 --- a/math32/matrix3.go +++ b/math32/matrix3.go @@ -186,16 +186,16 @@ func (m *Matrix3) SetMulScalar(s float32) { m[8] *= s } -// MulVector2AsVector multiplies the Vector2 as a vector without adding translations. +// MulVector multiplies the Vector2 as a vector without adding translations. // This is for directional vectors and not points. -func (a Matrix3) MulVector2AsVector(v Vector2) Vector2 { +func (a Matrix3) MulVector(v Vector2) Vector2 { tx := a[0]*v.X + a[1]*v.Y ty := a[3]*v.X + a[4]*v.Y return Vec2(tx, ty) } -// MulVector2AsPoint multiplies the Vector2 as a point, including adding translations. -func (a Matrix3) MulVector2AsPoint(v Vector2) Vector2 { +// MulPoint multiplies the Vector2 as a point, including adding translations. +func (a Matrix3) MulPoint(v Vector2) Vector2 { tx := a[0]*v.X + a[1]*v.Y + a[2] ty := a[3]*v.X + a[4]*v.Y + a[5] return Vec2(tx, ty) @@ -323,8 +323,7 @@ func (m *Matrix3) SetScaleCols(v Vector3) { m[8] *= v.Z } -///////////////////////////////////////////////////////////////////////////// -// Special functions +//////// Special functions // SetNormalMatrix set this matrix to the matrix that can transform the normal vectors // from the src matrix which is used transform the vertices (e.g., a ModelView matrix). diff --git a/math32/matrix3_test.go b/math32/matrix3_test.go index af8c22d933..9f8bafeb74 100644 --- a/math32/matrix3_test.go +++ b/math32/matrix3_test.go @@ -16,27 +16,27 @@ func TestMatrix3(t *testing.T) { vy := Vec2(0, 1) vxy := Vec2(1, 1) - assert.Equal(t, vx, Identity3().MulVector2AsPoint(vx)) - assert.Equal(t, vy, Identity3().MulVector2AsPoint(vy)) - assert.Equal(t, vxy, Identity3().MulVector2AsPoint(vxy)) + assert.Equal(t, vx, Identity3().MulPoint(vx)) + assert.Equal(t, vy, Identity3().MulPoint(vy)) + assert.Equal(t, vxy, Identity3().MulPoint(vxy)) - assert.Equal(t, vxy, Matrix3FromMatrix2(Translate2D(1, 1)).MulVector2AsPoint(v0)) + assert.Equal(t, vxy, Matrix3FromMatrix2(Translate2D(1, 1)).MulPoint(v0)) - assert.Equal(t, vxy.MulScalar(2), Matrix3FromMatrix2(Scale2D(2, 2)).MulVector2AsPoint(vxy)) + assert.Equal(t, vxy.MulScalar(2), Matrix3FromMatrix2(Scale2D(2, 2)).MulPoint(vxy)) - tolAssertEqualVector(t, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).MulVector2AsPoint(vx)) // left - tolAssertEqualVector(t, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).MulVector2AsPoint(vy)) // right - tolAssertEqualVector(t, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(45))).MulVector2AsPoint(vx)) - tolAssertEqualVector(t, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(-45))).MulVector2AsPoint(vy)) + tolAssertEqualVector(t, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).MulPoint(vx)) // left + tolAssertEqualVector(t, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).MulPoint(vy)) // right + tolAssertEqualVector(t, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(45))).MulPoint(vx)) + tolAssertEqualVector(t, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(-45))).MulPoint(vy)) - tolAssertEqualVector(t, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).Inverse().MulVector2AsPoint(vx)) // left - tolAssertEqualVector(t, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).Inverse().MulVector2AsPoint(vy)) // right + tolAssertEqualVector(t, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).Inverse().MulPoint(vx)) // left + tolAssertEqualVector(t, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).Inverse().MulPoint(vy)) // right // 1,0 -> scale(2) = 2,0 -> rotate 90 = 0,2 -> trans 1,1 -> 1,3 // multiplication order is *reverse* of "logical" order: - tolAssertEqualVector(t, Vec2(1, 3), Matrix3Translate2D(1, 1).Mul(Matrix3Rotate2D(DegToRad(90))).Mul(Matrix3Scale2D(2, 2)).MulVector2AsPoint(vx)) + tolAssertEqualVector(t, Vec2(1, 3), Matrix3Translate2D(1, 1).Mul(Matrix3Rotate2D(DegToRad(90))).Mul(Matrix3Scale2D(2, 2)).MulPoint(vx)) - // xmat := Matrix3Translate2D(1, 1).Mul(Matrix3Rotate2D(DegToRad(90))).Mul(Matrix3Scale2D(2, 2)).MulVector2AsPoint(vx)) + // xmat := Matrix3Translate2D(1, 1).Mul(Matrix3Rotate2D(DegToRad(90))).Mul(Matrix3Scale2D(2, 2)).MulPoint(vx)) } func TestMatrix3SetFromMatrix4(t *testing.T) { diff --git a/math32/quaternion.go b/math32/quaternion.go index 4f822b2bf8..96bf844ecb 100644 --- a/math32/quaternion.go +++ b/math32/quaternion.go @@ -282,27 +282,20 @@ func (q *Quat) NormalizeFast() { } } -// MulQuats set this quaternion to the multiplication of a by b. -func (q *Quat) MulQuats(a, b Quat) { +// MulQuats returns a * b quaternion multiplication. +func MulQuats(a, b Quat) Quat { // from http://www.euclideanspace.com/maths/algebra/realNormedAlgebra/quaternions/code/index.htm - qax := a.X - qay := a.Y - qaz := a.Z - qaw := a.W - qbx := b.X - qby := b.Y - qbz := b.Z - qbw := b.W - - q.X = qax*qbw + qaw*qbx + qay*qbz - qaz*qby - q.Y = qay*qbw + qaw*qby + qaz*qbx - qax*qbz - q.Z = qaz*qbw + qaw*qbz + qax*qby - qay*qbx - q.W = qaw*qbw - qax*qbx - qay*qby - qaz*qbz + var q Quat + q.X = a.X*b.W + a.W*b.X + a.Y*b.Z - a.Z*b.Y + q.Y = a.Y*b.W + a.W*b.Y + a.Z*b.X - a.X*b.Z + q.Z = a.Z*b.W + a.W*b.Z + a.X*b.Y - a.Y*b.X + q.W = a.W*b.W - a.X*b.X - a.Y*b.Y - a.Z*b.Z + return q } // SetMul sets this quaternion to the multiplication of itself by other. func (q *Quat) SetMul(other Quat) { - q.MulQuats(*q, other) + *q = MulQuats(*q, other) } // Mul returns returns multiplication of this quaternion with other diff --git a/math32/quaternion_test.go b/math32/quaternion_test.go index 00615805ba..67f268383e 100644 --- a/math32/quaternion_test.go +++ b/math32/quaternion_test.go @@ -104,8 +104,7 @@ func TestQuatMulQuats(t *testing.T) { q1 := Quat{X: 1, Y: 2, Z: 3, W: 4} q2 := Quat{X: 5, Y: 6, Z: 7, W: 8} - q := Quat{} - q.MulQuats(q1, q2) + q := MulQuats(q1, q2) expected := Quat{X: 24, Y: 48, Z: 48, W: -6} diff --git a/math32/vector3.go b/math32/vector3.go index aec01dc91b..0dfff9391c 100644 --- a/math32/vector3.go +++ b/math32/vector3.go @@ -416,19 +416,15 @@ func (v Vector3) MulProjection(m *Matrix4) Vector3 { // then by the quaternion inverse. // It basically applies the rotation encoded in the quaternion to this vector. func (v Vector3) MulQuat(q Quat) Vector3 { - qx := q.X - qy := q.Y - qz := q.Z - qw := q.W // calculate quat * vector - ix := qw*v.X + qy*v.Z - qz*v.Y - iy := qw*v.Y + qz*v.X - qx*v.Z - iz := qw*v.Z + qx*v.Y - qy*v.X - iw := -qx*v.X - qy*v.Y - qz*v.Z + ix := q.W*v.X + q.Y*v.Z - q.Z*v.Y + iy := q.W*v.Y + q.Z*v.X - q.X*v.Z + iz := q.W*v.Z + q.X*v.Y - q.Y*v.X + iw := -q.X*v.X - q.Y*v.Y - q.Z*v.Z // calculate result * inverse quat - return Vector3{ix*qw + iw*-qx + iy*-qz - iz*-qy, - iy*qw + iw*-qy + iz*-qx - ix*-qz, - iz*qw + iw*-qz + ix*-qy - iy*-qx} + return Vec3(ix*q.W+iw*-q.X+iy*-q.Z-iz*-q.Y, + iy*q.W+iw*-q.Y+iz*-q.X-ix*-q.Z, + iz*q.W+iw*-q.Z+ix*-q.Y-iy*-q.X) } // Cross returns the cross product of this vector with other. diff --git a/paint/painter.go b/paint/painter.go index a22998bd72..486eb71362 100644 --- a/paint/painter.go +++ b/paint/painter.go @@ -448,8 +448,8 @@ func (pc *Painter) DrawBox(pos, size math32.Vector2, img image.Image, op draw.Op if img == nil { img = colors.Uniform(color.RGBA{}) } - pos = pc.Cumulative().MulVector2AsPoint(pos) - size = pc.Cumulative().MulVector2AsVector(size) + pos = pc.Cumulative().MulPoint(pos) + size = pc.Cumulative().MulVector(size) br := math32.RectFromPosSizeMax(pos, size) cb := pc.Context().Bounds.Rect.ToRect() b := cb.Intersect(br) @@ -560,8 +560,8 @@ func (pc *Painter) BoundingBox(minX, minY, maxX, maxY float32) image.Rectangle { // if pc.Stroke.Color != nil {// todo // sw = 0.5 * pc.StrokeWidth() // } - tmin := pc.Cumulative().MulVector2AsPoint(math32.Vec2(minX, minY)) - tmax := pc.Cumulative().MulVector2AsPoint(math32.Vec2(maxX, maxY)) + tmin := pc.Cumulative().MulPoint(math32.Vec2(minX, minY)) + tmax := pc.Cumulative().MulPoint(math32.Vec2(maxX, maxY)) tp1 := math32.Vec2(tmin.X-sw, tmin.Y-sw).ToPointFloor() tp2 := math32.Vec2(tmax.X+sw, tmax.Y+sw).ToPointCeil() return image.Rect(tp1.X, tp1.Y, tp2.X, tp2.Y) diff --git a/paint/pdf/links.go b/paint/pdf/links.go index 2a877bccfc..5f57cd5c03 100644 --- a/paint/pdf/links.go +++ b/paint/pdf/links.go @@ -14,7 +14,7 @@ import ( // transform for scaling and flipping of coordinates to top-left system. func (w *pdfPage) AddAnchor(name string, pos math32.Vector2) { ms := math32.Scale2D(w.pdf.globalScale, w.pdf.globalScale) - pos = ms.MulVector2AsPoint(pos) + pos = ms.MulPoint(pos) if w.pdf.anchors == nil { w.pdf.anchors = make(pdfMap) } diff --git a/paint/pdf/page.go b/paint/pdf/page.go index 6271bf9571..5d69e00449 100644 --- a/paint/pdf/page.go +++ b/paint/pdf/page.go @@ -70,10 +70,10 @@ func (w *pdfPage) DrawImage(img image.Image, m math32.Matrix2) { // add clipping path around image for smooth edges when rotating outerRect := math32.B2(0.0, 0.0, float32(size.X), float32(size.Y)).MulMatrix2(m) - bl := m.MulVector2AsPoint(math32.Vector2{0, 0}) - br := m.MulVector2AsPoint(math32.Vector2{float32(size.X), 0}) - tl := m.MulVector2AsPoint(math32.Vector2{0, float32(size.Y)}) - tr := m.MulVector2AsPoint(math32.Vector2{float32(size.X), float32(size.Y)}) + bl := m.MulPoint(math32.Vector2{0, 0}) + br := m.MulPoint(math32.Vector2{float32(size.X), 0}) + tl := m.MulPoint(math32.Vector2{0, float32(size.Y)}) + tr := m.MulPoint(math32.Vector2{float32(size.X), float32(size.Y)}) fmt.Fprintf(w, " q %v %v %v %v re W n", dec(outerRect.Min.X), dec(outerRect.Min.Y), dec(outerRect.Size().X), dec(outerRect.Size().Y)) fmt.Fprintf(w, " %v %v m %v %v l %v %v l %v %v l h W n", dec(bl.X), dec(bl.Y), dec(tl.X), dec(tl.Y), dec(tr.X), dec(tr.Y), dec(br.X), dec(br.Y)) diff --git a/paint/ppath/transform.go b/paint/ppath/transform.go index 401921b51d..fafb2541e9 100644 --- a/paint/ppath/transform.go +++ b/paint/ppath/transform.go @@ -25,20 +25,20 @@ func (p Path) Transform(m math32.Matrix2) Path { fmt.Println("path length error:", len(p), i, p) return p } - end := m.MulVector2AsPoint(math32.Vec2(p[i+1], p[i+2])) + end := m.MulPoint(math32.Vec2(p[i+1], p[i+2])) p[i+1] = end.X p[i+2] = end.Y case QuadTo: - cp := m.MulVector2AsPoint(math32.Vec2(p[i+1], p[i+2])) - end := m.MulVector2AsPoint(math32.Vec2(p[i+3], p[i+4])) + cp := m.MulPoint(math32.Vec2(p[i+1], p[i+2])) + end := m.MulPoint(math32.Vec2(p[i+3], p[i+4])) p[i+1] = cp.X p[i+2] = cp.Y p[i+3] = end.X p[i+4] = end.Y case CubeTo: - cp1 := m.MulVector2AsPoint(math32.Vec2(p[i+1], p[i+2])) - cp2 := m.MulVector2AsPoint(math32.Vec2(p[i+3], p[i+4])) - end := m.MulVector2AsPoint(math32.Vec2(p[i+5], p[i+6])) + cp1 := m.MulPoint(math32.Vec2(p[i+1], p[i+2])) + cp2 := m.MulPoint(math32.Vec2(p[i+3], p[i+4])) + end := m.MulPoint(math32.Vec2(p[i+5], p[i+6])) p[i+1] = cp1.X p[i+2] = cp1.Y p[i+3] = cp2.X @@ -77,7 +77,7 @@ func (p Path) Transform(m math32.Matrix2) Path { if xscale*yscale < 0.0 { // flip x or y axis needs flipping of the sweep sweep = !sweep } - end = m.MulVector2AsPoint(end) + end = m.MulPoint(end) p[i+1] = rx p[i+2] = ry diff --git a/paint/renderers/rasterx/renderer.go b/paint/renderers/rasterx/renderer.go index 647af02c3b..c6a270c82b 100644 --- a/paint/renderers/rasterx/renderer.go +++ b/paint/renderers/rasterx/renderer.go @@ -92,18 +92,18 @@ func (rs *Renderer) RenderPath(pt *render.Path) { func PathToRasterx(rs Adder, p ppath.Path, m math32.Matrix2, off math32.Vector2) { for s := p.Scanner(); s.Scan(); { cmd := s.Cmd() - end := m.MulVector2AsPoint(s.End()).Add(off) + end := m.MulPoint(s.End()).Add(off) switch cmd { case ppath.MoveTo: rs.Start(end.ToFixed()) case ppath.LineTo: rs.Line(end.ToFixed()) case ppath.QuadTo: - cp1 := m.MulVector2AsPoint(s.CP1()).Add(off) + cp1 := m.MulPoint(s.CP1()).Add(off) rs.QuadBezier(cp1.ToFixed(), end.ToFixed()) case ppath.CubeTo: - cp1 := m.MulVector2AsPoint(s.CP1()).Add(off) - cp2 := m.MulVector2AsPoint(s.CP2()).Add(off) + cp1 := m.MulPoint(s.CP1()).Add(off) + cp2 := m.MulPoint(s.CP2()).Add(off) rs.CubeBezier(cp1.ToFixed(), cp2.ToFixed(), end.ToFixed()) case ppath.Close: rs.Stop(true) diff --git a/paint/renderers/rasterx/shapes.go b/paint/renderers/rasterx/shapes.go index 31bd4f5294..2586e6a81b 100644 --- a/paint/renderers/rasterx/shapes.go +++ b/paint/renderers/rasterx/shapes.go @@ -33,7 +33,7 @@ func AddCircle(cx, cy, r float32, p Adder) { // x and y radius, (rx, ry), rotated around the center by rot degrees. func AddEllipse(cx, cy, rx, ry, rot float32, p Adder) { rotRads := rot * math32.Pi / 180 - pt := math32.Identity2().Translate(cx, cy).Rotate(rotRads).Translate(-cx, -cy).MulVector2AsPoint(math32.Vec2(cx+rx, cy)) + pt := math32.Identity2().Translate(cx, cy).Rotate(rotRads).Translate(-cx, -cy).MulPoint(math32.Vec2(cx+rx, cy)) points := []float32{rx, ry, rot, 1.0, 0.0, pt.X, pt.Y} p.Start(pt.ToFixed()) AddArc(points, cx, cy, pt.X, pt.Y, p) diff --git a/paint/renderers/rasterx/text.go b/paint/renderers/rasterx/text.go index 36f895e0c8..8ebac88fa9 100644 --- a/paint/renderers/rasterx/text.go +++ b/paint/renderers/rasterx/text.go @@ -206,18 +206,18 @@ func (rs *Renderer) GlyphOutline(ctx *render.Context, run *shapedgt.Run, g *shap rs.Path.Clear() m := ctx.Cumulative for _, s := range outline.Segments { - p0 := m.MulVector2AsPoint(math32.Vec2(s.Args[0].X*scale+x, -s.Args[0].Y*scale+y)) + p0 := m.MulPoint(math32.Vec2(s.Args[0].X*scale+x, -s.Args[0].Y*scale+y)) switch s.Op { case opentype.SegmentOpMoveTo: rs.Path.Start(p0.ToFixed()) case opentype.SegmentOpLineTo: rs.Path.Line(p0.ToFixed()) case opentype.SegmentOpQuadTo: - p1 := m.MulVector2AsPoint(math32.Vec2(s.Args[1].X*scale+x, -s.Args[1].Y*scale+y)) + p1 := m.MulPoint(math32.Vec2(s.Args[1].X*scale+x, -s.Args[1].Y*scale+y)) rs.Path.QuadBezier(p0.ToFixed(), p1.ToFixed()) case opentype.SegmentOpCubeTo: - p1 := m.MulVector2AsPoint(math32.Vec2(s.Args[1].X*scale+x, -s.Args[1].Y*scale+y)) - p2 := m.MulVector2AsPoint(math32.Vec2(s.Args[2].X*scale+x, -s.Args[2].Y*scale+y)) + p1 := m.MulPoint(math32.Vec2(s.Args[1].X*scale+x, -s.Args[1].Y*scale+y)) + p2 := m.MulPoint(math32.Vec2(s.Args[2].X*scale+x, -s.Args[2].Y*scale+y)) rs.Path.CubeBezier(p0.ToFixed(), p1.ToFixed(), p2.ToFixed()) } } @@ -266,10 +266,10 @@ func (rs *Renderer) StrokeBounds(ctx *render.Context, bb math32.Box2, clr color. nil, 0) rs.Raster.SetColor(colors.Uniform(clr)) m := ctx.Cumulative - rs.Raster.Start(m.MulVector2AsPoint(math32.Vec2(bb.Min.X, bb.Min.Y)).ToFixed()) - rs.Raster.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Min.Y)).ToFixed()) - rs.Raster.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Max.Y)).ToFixed()) - rs.Raster.Line(m.MulVector2AsPoint(math32.Vec2(bb.Min.X, bb.Max.Y)).ToFixed()) + rs.Raster.Start(m.MulPoint(math32.Vec2(bb.Min.X, bb.Min.Y)).ToFixed()) + rs.Raster.Line(m.MulPoint(math32.Vec2(bb.Max.X, bb.Min.Y)).ToFixed()) + rs.Raster.Line(m.MulPoint(math32.Vec2(bb.Max.X, bb.Max.Y)).ToFixed()) + rs.Raster.Line(m.MulPoint(math32.Vec2(bb.Min.X, bb.Max.Y)).ToFixed()) rs.Raster.Stop(true) rs.Raster.Draw() rs.Raster.Clear() @@ -278,8 +278,8 @@ func (rs *Renderer) StrokeBounds(ctx *render.Context, bb math32.Box2, clr color. // StrokeTextLine strokes a line for text decoration. func (rs *Renderer) StrokeTextLine(ctx *render.Context, sp, ep math32.Vector2, width float32, clr image.Image, dash []float32) { m := ctx.Cumulative - sp = m.MulVector2AsPoint(sp) - ep = m.MulVector2AsPoint(ep) + sp = m.MulPoint(sp) + ep = m.MulPoint(ep) width *= MeanScale(m) rs.Raster.SetStroke( math32.ToFixed(width), @@ -299,10 +299,10 @@ func (rs *Renderer) FillBounds(ctx *render.Context, bb math32.Box2, clr image.Im rf := &rs.Raster.Filler rf.SetColor(clr) m := ctx.Cumulative - rf.Start(m.MulVector2AsPoint(math32.Vec2(bb.Min.X, bb.Min.Y)).ToFixed()) - rf.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Min.Y)).ToFixed()) - rf.Line(m.MulVector2AsPoint(math32.Vec2(bb.Max.X, bb.Max.Y)).ToFixed()) - rf.Line(m.MulVector2AsPoint(math32.Vec2(bb.Min.X, bb.Max.Y)).ToFixed()) + rf.Start(m.MulPoint(math32.Vec2(bb.Min.X, bb.Min.Y)).ToFixed()) + rf.Line(m.MulPoint(math32.Vec2(bb.Max.X, bb.Min.Y)).ToFixed()) + rf.Line(m.MulPoint(math32.Vec2(bb.Max.X, bb.Max.Y)).ToFixed()) + rf.Line(m.MulPoint(math32.Vec2(bb.Min.X, bb.Max.Y)).ToFixed()) rf.Stop(true) rf.Draw() rf.Clear() diff --git a/svg/circle.go b/svg/circle.go index 6c230def5b..52c44450f8 100644 --- a/svg/circle.go +++ b/svg/circle.go @@ -59,7 +59,7 @@ func (g *Circle) ApplyTransform(sv *SVG, xf math32.Matrix2) { g.Paint.Transform.SetMul(xf) g.SetTransformProperty() } else { - g.Pos = xf.MulVector2AsPoint(g.Pos) + g.Pos = xf.MulPoint(g.Pos) scx, scy := xf.ExtractScale() g.Radius *= 0.5 * (scx + scy) g.GradientApplyTransform(sv, xf) diff --git a/svg/ellipse.go b/svg/ellipse.go index 74826dd920..dc3f354020 100644 --- a/svg/ellipse.go +++ b/svg/ellipse.go @@ -60,8 +60,8 @@ func (g *Ellipse) ApplyTransform(sv *SVG, xf math32.Matrix2) { g.SetTransformProperty() } else { // todo: this is not the correct transform: - g.Pos = xf.MulVector2AsPoint(g.Pos) - g.Radii = xf.MulVector2AsVector(g.Radii) + g.Pos = xf.MulPoint(g.Pos) + g.Radii = xf.MulVector(g.Radii) g.GradientApplyTransform(sv, xf) } } diff --git a/svg/image.go b/svg/image.go index 345d88c3a2..1d391b1d5f 100644 --- a/svg/image.go +++ b/svg/image.go @@ -130,8 +130,8 @@ func (g *Image) ApplyTransform(sv *SVG, xf math32.Matrix2) { g.Paint.Transform.SetMul(xf) g.SetTransformProperty() } else { - g.Pos = xf.MulVector2AsPoint(g.Pos) - g.Size = xf.MulVector2AsVector(g.Size) + g.Pos = xf.MulPoint(g.Pos) + g.Size = xf.MulVector(g.Size) g.GradientApplyTransform(sv, xf) } } diff --git a/svg/line.go b/svg/line.go index 9d5ce2a953..f58f20324a 100644 --- a/svg/line.go +++ b/svg/line.go @@ -72,8 +72,8 @@ func (g *Line) ApplyTransform(sv *SVG, xf math32.Matrix2) { g.Paint.Transform.SetMul(xf) g.SetTransformProperty() } else { - g.Start = xf.MulVector2AsPoint(g.Start) - g.End = xf.MulVector2AsPoint(g.End) + g.Start = xf.MulPoint(g.Start) + g.End = xf.MulPoint(g.End) g.GradientApplyTransform(sv, xf) } } diff --git a/svg/node.go b/svg/node.go index ce7294efc1..882534f948 100644 --- a/svg/node.go +++ b/svg/node.go @@ -177,8 +177,8 @@ func (g *NodeBase) ApplyTransform(sv *SVG, xf math32.Matrix2) { // operating around given reference point which serves as the effective origin for rotation. func (g *NodeBase) DeltaTransform(trans math32.Vector2, scale math32.Vector2, rot float32, pt math32.Vector2) math32.Matrix2 { mxi := g.ParentTransform(true).Inverse() - lpt := mxi.MulVector2AsPoint(pt) - ltr := mxi.MulVector2AsVector(trans) + lpt := mxi.MulPoint(pt) + ltr := mxi.MulVector(trans) xf := math32.Translate2D(lpt.X, lpt.Y).Scale(scale.X, scale.Y).Rotate(rot).Translate(ltr.X, ltr.Y).Translate(-lpt.X, -lpt.Y) return xf } diff --git a/svg/polyline.go b/svg/polyline.go index 13f6fe1c23..5918c972f7 100644 --- a/svg/polyline.go +++ b/svg/polyline.go @@ -80,7 +80,7 @@ func (g *Polyline) ApplyTransform(sv *SVG, xf math32.Matrix2) { g.SetTransformProperty() } else { for i, p := range g.Points { - p = xf.MulVector2AsPoint(p) + p = xf.MulPoint(p) g.Points[i] = p } g.GradientApplyTransform(sv, xf) diff --git a/svg/rect.go b/svg/rect.go index 322027a5ad..b5b89562cc 100644 --- a/svg/rect.go +++ b/svg/rect.go @@ -70,8 +70,8 @@ func (g *Rect) ApplyTransform(sv *SVG, xf math32.Matrix2) { g.Paint.Transform.SetMul(xf) g.SetTransformProperty() } else { - g.Pos = xf.MulVector2AsPoint(g.Pos) - g.Size = xf.MulVector2AsVector(g.Size) + g.Pos = xf.MulPoint(g.Pos) + g.Size = xf.MulVector(g.Size) g.GradientApplyTransform(sv, xf) } } diff --git a/svg/text.go b/svg/text.go index d1306d6535..4b44c9c4f8 100644 --- a/svg/text.go +++ b/svg/text.go @@ -74,7 +74,7 @@ func (g *Text) SetNodePos(pos math32.Vector2) { g.Pos = pos for _, kii := range g.Children { kt := kii.(*Text) - kt.Pos = g.Paint.Transform.MulVector2AsPoint(pos) + kt.Pos = g.Paint.Transform.MulPoint(pos) } } @@ -193,7 +193,7 @@ func (g *Text) ApplyTransform(sv *SVG, xf math32.Matrix2) { g.Paint.Transform.SetMul(xf) g.SetTransformProperty() } else { - g.Pos = xf.MulVector2AsPoint(g.Pos) + g.Pos = xf.MulPoint(g.Pos) g.GradientApplyTransform(sv, xf) } } diff --git a/svg/zoom.go b/svg/zoom.go index 1669618786..338d598417 100644 --- a/svg/zoom.go +++ b/svg/zoom.go @@ -112,12 +112,12 @@ func (sv *SVG) ScaleAt(pt image.Point, sc float32) { sv.setRootTransform() rxf := sv.Root.Paint.Transform.Inverse() mpt := math32.FromPoint(pt) - xpt := rxf.MulVector2AsPoint(mpt) + xpt := rxf.MulPoint(mpt) sv.Scale = sc sv.setRootTransform() rxf = sv.Root.Paint.Transform - npt := rxf.MulVector2AsPoint(xpt) // original point back to screen + npt := rxf.MulPoint(xpt) // original point back to screen dpt := mpt.Sub(npt) sv.Translate.SetAdd(dpt) } From d66b2085f266c692f7b8c48dfdfaad2db39e014f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 15 Dec 2025 07:00:44 +0100 Subject: [PATCH 76/99] pdf: more cleanup --- math32/matrix2.go | 6 ++---- paint/renderers/rasterx/geom.go | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/math32/matrix2.go b/math32/matrix2.go index 0a5468cb8c..c0f00a3033 100644 --- a/math32/matrix2.go +++ b/math32/matrix2.go @@ -133,8 +133,6 @@ func (a *Matrix2) SetMul(b Matrix2) { *a = a.Mul(b) } -// todo: rename to MulVector, MulPoint - // MulVector multiplies the Vector2 as a vector without adding translations. // This is for directional vectors and not points. func (a Matrix2) MulVector(v Vector2) Vector2 { @@ -150,8 +148,8 @@ func (a Matrix2) MulPoint(v Vector2) Vector2 { return Vec2(tx, ty) } -// MulFixedAsPoint multiplies the fixed point as a point, including adding translations. -func (a Matrix2) MulFixedAsPoint(fp fixed.Point26_6) fixed.Point26_6 { +// MulFixedPoint multiplies the fixed point as a point, including adding translations. +func (a Matrix2) MulFixedPoint(fp fixed.Point26_6) fixed.Point26_6 { x := fixed.Int26_6((float32(fp.X)*a.XX + float32(fp.Y)*a.XY) + a.X0*32) y := fixed.Int26_6((float32(fp.X)*a.YX + float32(fp.Y)*a.YY) + a.Y0*32) return fixed.Point26_6{x, y} diff --git a/paint/renderers/rasterx/geom.go b/paint/renderers/rasterx/geom.go index 655a8e69b9..840ebf1177 100644 --- a/paint/renderers/rasterx/geom.go +++ b/paint/renderers/rasterx/geom.go @@ -322,20 +322,20 @@ func (t *MatrixAdder) Reset() { // Start starts a new path func (t *MatrixAdder) Start(a fixed.Point26_6) { - t.Adder.Start(t.M.MulFixedAsPoint(a)) + t.Adder.Start(t.M.MulFixedPoint(a)) } // Line adds a linear segment to the current curve. func (t *MatrixAdder) Line(b fixed.Point26_6) { - t.Adder.Line(t.M.MulFixedAsPoint(b)) + t.Adder.Line(t.M.MulFixedPoint(b)) } // QuadBezier adds a quadratic segment to the current curve. func (t *MatrixAdder) QuadBezier(b, c fixed.Point26_6) { - t.Adder.QuadBezier(t.M.MulFixedAsPoint(b), t.M.MulFixedAsPoint(c)) + t.Adder.QuadBezier(t.M.MulFixedPoint(b), t.M.MulFixedPoint(c)) } // CubeBezier adds a cubic segment to the current curve. func (t *MatrixAdder) CubeBezier(b, c, d fixed.Point26_6) { - t.Adder.CubeBezier(t.M.MulFixedAsPoint(b), t.M.MulFixedAsPoint(c), t.M.MulFixedAsPoint(d)) + t.Adder.CubeBezier(t.M.MulFixedPoint(b), t.M.MulFixedPoint(c), t.M.MulFixedPoint(d)) } From 9ed2447ed1620127724987ed48643d2e80fac919 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 15 Dec 2025 09:47:34 +0100 Subject: [PATCH 77/99] pdf: Matrix3 constructor --- math32/matrix2.go | 2 +- math32/matrix3.go | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/math32/matrix2.go b/math32/matrix2.go index c0f00a3033..533c495b32 100644 --- a/math32/matrix2.go +++ b/math32/matrix2.go @@ -39,7 +39,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Matrix2 is a 3x2 matrix. +// Matrix2 is a 2x3 matrix. // [XX YX] // [XY YY] // [X0 Y0] diff --git a/math32/matrix3.go b/math32/matrix3.go index 65fbbde3e6..90b59ea9e0 100644 --- a/math32/matrix3.go +++ b/math32/matrix3.go @@ -15,6 +15,13 @@ import "errors" // Matrix3 is 3x3 matrix organized internally as column matrix. type Matrix3 [9]float32 +// Mat3 constructs a new Matrix3 from given values, in column-wise order. +func Mat3(n11, n21, n31, n12, n22, n32, n13, n23, n33 float32) Matrix3 { + m := Matrix3{} + m.SetCols(n11, n21, n31, n12, n22, n32, n13, n23, n33) + return m +} + // Identity3 returns a new identity [Matrix3] matrix. func Identity3() Matrix3 { m := Matrix3{} @@ -49,23 +56,37 @@ func Matrix3Rotate2D(angle float32) Matrix3 { return Matrix3FromMatrix2(Rotate2D(angle)) } -// Set sets all the elements of the matrix row by row starting at row1, column1, -// row1, column2, row1, column3 and so forth. -func (m *Matrix3) Set(n11, n12, n13, n21, n22, n23, n31, n32, n33 float32) { +// SetCols sets all the elements of the matrix col by col starting at row1, column1, +// row2, column1, row3, column1 and so forth. +func (m *Matrix3) SetCols(n11, n21, n31, n12, n22, n32, n13, n23, n33 float32) { m[0] = n11 - m[3] = n12 - m[6] = n13 m[1] = n21 + m[2] = n31 + m[3] = n12 m[4] = n22 + m[5] = n32 + m[6] = n13 m[7] = n23 + m[8] = n33 +} + +// SetRows sets all the elements of the matrix row by row starting at row1, column1, +// row1, column2, row1, column3 and so forth. +func (m *Matrix3) SetRows(n11, n12, n13, n21, n22, n23, n31, n32, n33 float32) { + m[0] = n11 + m[1] = n21 m[2] = n31 + m[3] = n12 + m[4] = n22 m[5] = n32 + m[6] = n13 + m[7] = n23 m[8] = n33 } // SetFromMatrix4 sets the matrix elements based on a Matrix4. func (m *Matrix3) SetFromMatrix4(src *Matrix4) { - m.Set( + m.SetRows( src[0], src[4], src[8], src[1], src[5], src[9], src[2], src[6], src[10], @@ -78,7 +99,7 @@ func (m *Matrix3) SetFromMatrix4(src *Matrix4) { // SetFromMatrix2 sets the matrix elements based on a Matrix2. func (m *Matrix3) SetFromMatrix2(src Matrix2) { - m.Set( + m.SetRows( src.XX, src.YX, src.X0, src.XY, src.YY, src.Y0, src.X0, src.Y0, 1, @@ -97,7 +118,7 @@ func (m Matrix3) ToArray(array []float32, offset int) { // SetIdentity sets this matrix as the identity matrix. func (m *Matrix3) SetIdentity() { - m.Set( + m.SetRows( 1, 0, 0, 0, 1, 0, 0, 0, 1, @@ -106,7 +127,7 @@ func (m *Matrix3) SetIdentity() { // SetZero sets this matrix as the zero matrix. func (m *Matrix3) SetZero() { - m.Set( + m.SetRows( 0, 0, 0, 0, 0, 0, 0, 0, 0, From 95b6a48d720d16264076a6a216d9ffd61ee030fc Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 15 Dec 2025 12:34:43 +0100 Subject: [PATCH 78/99] pdf: move Vector3.MulQuat to Quat.MulVector --- math32/box3.go | 18 ++++---- math32/matrix3.go | 41 +++++++----------- math32/quaternion.go | 88 ++++++++++++++++++++++++++++----------- math32/quaternion_test.go | 14 +++++++ math32/vector3.go | 15 ------- xyz/camera.go | 10 ++--- xyz/pose.go | 2 +- 7 files changed, 108 insertions(+), 80 deletions(-) diff --git a/math32/box3.go b/math32/box3.go index 86092360c8..aa080cc150 100644 --- a/math32/box3.go +++ b/math32/box3.go @@ -215,15 +215,15 @@ func (b Box3) MulMatrix4(m *Matrix4) Box3 { // and computes the resulting spanning Box3 of the transformed points func (b Box3) MulQuat(q Quat) Box3 { var cs [8]Vector3 - cs[0] = Vec3(b.Min.X, b.Min.Y, b.Min.Z).MulQuat(q) - cs[1] = Vec3(b.Min.X, b.Min.Y, b.Max.Z).MulQuat(q) - cs[2] = Vec3(b.Min.X, b.Max.Y, b.Min.Z).MulQuat(q) - cs[3] = Vec3(b.Max.X, b.Min.Y, b.Min.Z).MulQuat(q) - - cs[4] = Vec3(b.Max.X, b.Max.Y, b.Max.Z).MulQuat(q) - cs[5] = Vec3(b.Max.X, b.Max.Y, b.Min.Z).MulQuat(q) - cs[6] = Vec3(b.Max.X, b.Min.Y, b.Max.Z).MulQuat(q) - cs[7] = Vec3(b.Min.X, b.Max.Y, b.Max.Z).MulQuat(q) + cs[0] = q.MulVector(Vec3(b.Min.X, b.Min.Y, b.Min.Z)) + cs[1] = q.MulVector(Vec3(b.Min.X, b.Min.Y, b.Max.Z)) + cs[2] = q.MulVector(Vec3(b.Min.X, b.Max.Y, b.Min.Z)) + cs[3] = q.MulVector(Vec3(b.Max.X, b.Min.Y, b.Min.Z)) + + cs[4] = q.MulVector(Vec3(b.Max.X, b.Max.Y, b.Max.Z)) + cs[5] = q.MulVector(Vec3(b.Max.X, b.Max.Y, b.Min.Z)) + cs[6] = q.MulVector(Vec3(b.Max.X, b.Min.Y, b.Max.Z)) + cs[7] = q.MulVector(Vec3(b.Min.X, b.Max.Y, b.Max.Z)) nb := B3Empty() for i := 0; i < 8; i++ { diff --git a/math32/matrix3.go b/math32/matrix3.go index 90b59ea9e0..8ac6e576ce 100644 --- a/math32/matrix3.go +++ b/math32/matrix3.go @@ -15,10 +15,13 @@ import "errors" // Matrix3 is 3x3 matrix organized internally as column matrix. type Matrix3 [9]float32 +// note: matrix indexes are row,column. matrix dimensions are CxR. +// nothing like consistency... + // Mat3 constructs a new Matrix3 from given values, in column-wise order. func Mat3(n11, n21, n31, n12, n22, n32, n13, n23, n33 float32) Matrix3 { m := Matrix3{} - m.SetCols(n11, n21, n31, n12, n22, n32, n13, n23, n33) + m.Set(n11, n21, n31, n12, n22, n32, n13, n23, n33) return m } @@ -56,23 +59,9 @@ func Matrix3Rotate2D(angle float32) Matrix3 { return Matrix3FromMatrix2(Rotate2D(angle)) } -// SetCols sets all the elements of the matrix col by col starting at row1, column1, +// Set sets all the elements of the matrix col by col starting at row1, column1, // row2, column1, row3, column1 and so forth. -func (m *Matrix3) SetCols(n11, n21, n31, n12, n22, n32, n13, n23, n33 float32) { - m[0] = n11 - m[1] = n21 - m[2] = n31 - m[3] = n12 - m[4] = n22 - m[5] = n32 - m[6] = n13 - m[7] = n23 - m[8] = n33 -} - -// SetRows sets all the elements of the matrix row by row starting at row1, column1, -// row1, column2, row1, column3 and so forth. -func (m *Matrix3) SetRows(n11, n12, n13, n21, n22, n23, n31, n32, n33 float32) { +func (m *Matrix3) Set(n11, n21, n31, n12, n22, n32, n13, n23, n33 float32) { m[0] = n11 m[1] = n21 m[2] = n31 @@ -86,10 +75,10 @@ func (m *Matrix3) SetRows(n11, n12, n13, n21, n22, n23, n31, n32, n33 float32) { // SetFromMatrix4 sets the matrix elements based on a Matrix4. func (m *Matrix3) SetFromMatrix4(src *Matrix4) { - m.SetRows( - src[0], src[4], src[8], - src[1], src[5], src[9], - src[2], src[6], src[10], + m.Set( + src[0], src[1], src[2], + src[4], src[5], src[6], + src[8], src[9], src[10], ) } @@ -99,9 +88,9 @@ func (m *Matrix3) SetFromMatrix4(src *Matrix4) { // SetFromMatrix2 sets the matrix elements based on a Matrix2. func (m *Matrix3) SetFromMatrix2(src Matrix2) { - m.SetRows( - src.XX, src.YX, src.X0, - src.XY, src.YY, src.Y0, + m.Set( + src.XX, src.XY, src.X0, + src.YX, src.YY, src.Y0, src.X0, src.Y0, 1, ) } @@ -118,7 +107,7 @@ func (m Matrix3) ToArray(array []float32, offset int) { // SetIdentity sets this matrix as the identity matrix. func (m *Matrix3) SetIdentity() { - m.SetRows( + m.Set( 1, 0, 0, 0, 1, 0, 0, 0, 1, @@ -127,7 +116,7 @@ func (m *Matrix3) SetIdentity() { // SetZero sets this matrix as the zero matrix. func (m *Matrix3) SetZero() { - m.SetRows( + m.Set( 0, 0, 0, 0, 0, 0, 0, 0, 0, diff --git a/math32/quaternion.go b/math32/quaternion.go index 96bf844ecb..8dd8b14477 100644 --- a/math32/quaternion.go +++ b/math32/quaternion.go @@ -56,7 +56,7 @@ func (q *Quat) FromArray(array []float32, offset int) { } // ToArray copies this quaternions's components to array starting at offset. -func (q *Quat) ToArray(array []float32, offset int) { +func (q Quat) ToArray(array []float32, offset int) { array[offset] = q.X array[offset+1] = q.Y array[offset+2] = q.Z @@ -72,12 +72,12 @@ func (q *Quat) SetIdentity() { } // IsIdentity returns if this is an identity quaternion. -func (q *Quat) IsIdentity() bool { +func (q Quat) IsIdentity() bool { return q.X == 0 && q.Y == 0 && q.Z == 0 && q.W == 1 } // IsNil returns true if all values are 0 (uninitialized). -func (q *Quat) IsNil() bool { +func (q Quat) IsNil() bool { return q.X == 0 && q.Y == 0 && q.Z == 0 && q.W == 0 } @@ -104,9 +104,9 @@ func (q *Quat) SetFromEuler(euler Vector3) { // ToEuler returns a Vector3 with components as the Euler angles // from the given quaternion. -func (q *Quat) ToEuler() Vector3 { +func (q Quat) ToEuler() Vector3 { rot := Vector3{} - rot.SetEulerAnglesFromQuat(*q) + rot.SetEulerAnglesFromQuat(q) return rot } @@ -122,20 +122,20 @@ func (q *Quat) SetFromAxisAngle(axis Vector3, angle float32) { } // ToAxisAngle returns the Vector4 holding axis and angle of this Quaternion -func (q *Quat) ToAxisAngle() Vector4 { +func (q Quat) ToAxisAngle() Vector4 { aa := Vector4{} - aa.SetAxisAngleFromQuat(*q) + aa.SetAxisAngleFromQuat(q) return aa } // GenGoSet returns code to set values in object at given path (var.member etc) -func (q *Quat) GenGoSet(path string) string { +func (q Quat) GenGoSet(path string) string { aa := q.ToAxisAngle() return fmt.Sprintf("%s.SetFromAxisAngle(math32.Vec3(%g, %g, %g), %g)", path, aa.X, aa.Y, aa.Z, aa.W) } // GenGoNew returns code to create new -func (q *Quat) GenGoNew() string { +func (q Quat) GenGoNew() string { return fmt.Sprintf("math32.Quat{%g, %g, %g, %g}", q.X, q.Y, q.Z, q.W) } @@ -213,10 +213,9 @@ func (q *Quat) SetInverse() { } // Inverse returns the inverse of this quaternion. -func (q *Quat) Inverse() Quat { - nq := *q - nq.SetInverse() - return nq +func (q Quat) Inverse() Quat { + q.SetInverse() + return q } // SetConjugate sets this quaternion to its conjugate. @@ -227,14 +226,13 @@ func (q *Quat) SetConjugate() { } // Conjugate returns the conjugate of this quaternion. -func (q *Quat) Conjugate() Quat { - nq := *q - nq.SetConjugate() - return nq +func (q Quat) Conjugate() Quat { + q.SetConjugate() + return q } // Dot returns the dot products of this quaternion with other. -func (q *Quat) Dot(other Quat) float32 { +func (q Quat) Dot(other Quat) float32 { return q.X*other.X + q.Y*other.Y + q.Z*other.Z + q.W*other.W } @@ -299,14 +297,56 @@ func (q *Quat) SetMul(other Quat) { } // Mul returns returns multiplication of this quaternion with other -func (q *Quat) Mul(other Quat) Quat { - nq := *q - nq.SetMul(other) - return nq +func (q Quat) Mul(other Quat) Quat { + q.SetMul(other) + return q } -// Slerp sets this quaternion to another quaternion which is the spherically linear interpolation -// from this quaternion to other using t. +// MulVector applies the rotation encoded in the quaternion to the [Vector3]. +func (q Quat) MulVector(v Vector3) Vector3 { + // note: these each produce the same results when q is normalized + // but produce very different results for non-normalized, + // which is presumably not well defined anyway. + // According to IsaacLab, the cross-product version is faster, + // but the old version that it replaced is pretty weird. + // In any case, it is much simpler! + // https://github.com/isaac-sim/IsaacLab/issues/1711 + // https://github.com/isaac-sim/IsaacLab/pull/2129/files + + // original version: + // // calculate quat * vector + // ix := q.W*v.X + q.Y*v.Z - q.Z*v.Y + // iy := q.W*v.Y + q.Z*v.X - q.X*v.Z + // iz := q.W*v.Z + q.X*v.Y - q.Y*v.X + // iw := -q.X*v.X - q.Y*v.Y - q.Z*v.Z + // // calculate result * inverse quat + // return Vec3(ix*q.W+iw*-q.X+iy*-q.Z-iz*-q.Y, + // iy*q.W+iw*-q.Y+iz*-q.X-ix*-q.Z, + // iz*q.W+iw*-q.Z+ix*-q.Y-iy*-q.X) + + // warp version: + // c := 2*q.W*q.W - 1 + // d := 2 * (q.X*v.X + q.Y*v.Y + q.Z*v.Z) + // return Vec3(v.X*c+q.X*d+(q.Y*v.Z-q.Z*v.Y)*q.W*2, + // v.Y*c+q.Y*d+(q.Z*v.X-q.X*v.Z)*q.W*2, + // v.Z*c+q.Z*d+(q.X*v.Y-q.Y*v.X)*q.W*2) + + // isaacLab + xyz := Vec3(q.X, q.Y, q.Z) + t := xyz.Cross(v).MulScalar(2) + return v.Add(t.MulScalar(q.W)).Add(xyz.Cross(t)) +} + +// MulVectorInverse applies the inverse of the rotation encoded in the quaternion +// to the [Vector3]. +func (q Quat) MulVectorInverse(v Vector3) Vector3 { + xyz := Vec3(q.X, q.Y, q.Z) + t := xyz.Cross(v).MulScalar(2) + return v.Sub(t.MulScalar(q.W)).Add(xyz.Cross(t)) +} + +// Slerp sets this quaternion to another quaternion which is the +// spherically linear interpolation from this quaternion to other using t. func (q *Quat) Slerp(other Quat, t float32) { if t == 0 { return diff --git a/math32/quaternion_test.go b/math32/quaternion_test.go index 67f268383e..ae17e49969 100644 --- a/math32/quaternion_test.go +++ b/math32/quaternion_test.go @@ -111,6 +111,20 @@ func TestQuatMulQuats(t *testing.T) { assert.Equal(t, expected, q) } +func TestQuatMulVector(t *testing.T) { + q1 := Quat{X: 1, Y: 2, Z: 3, W: 4} + q1.Normalize() // note: critical + v := Vector3{X: 5, Y: 6, Z: 7} + + vr := q1.MulVector(v) + + expected := Vector3{X: 1.7999997, Y: 7.6, Z: 7} + assert.Equal(t, expected, vr) + + viv := q1.MulVectorInverse(q1.MulVector(v)) + assert.Equal(t, v, viv) +} + func TestQuatSlerp(t *testing.T) { q1 := Quat{X: 1, Y: 2, Z: 3, W: 4} q2 := Quat{X: 5, Y: 6, Z: 7, W: 8} diff --git a/math32/vector3.go b/math32/vector3.go index 0dfff9391c..ca5255a2a5 100644 --- a/math32/vector3.go +++ b/math32/vector3.go @@ -412,21 +412,6 @@ func (v Vector3) MulProjection(m *Matrix4) Vector3 { (m[2]*v.X + m[6]*v.Y + m[10]*v.Z + m[14]) * d} } -// MulQuat returns vector multiplied by specified quaternion and -// then by the quaternion inverse. -// It basically applies the rotation encoded in the quaternion to this vector. -func (v Vector3) MulQuat(q Quat) Vector3 { - // calculate quat * vector - ix := q.W*v.X + q.Y*v.Z - q.Z*v.Y - iy := q.W*v.Y + q.Z*v.X - q.X*v.Z - iz := q.W*v.Z + q.X*v.Y - q.Y*v.X - iw := -q.X*v.X - q.Y*v.Y - q.Z*v.Z - // calculate result * inverse quat - return Vec3(ix*q.W+iw*-q.X+iy*-q.Z-iz*-q.Y, - iy*q.W+iw*-q.Y+iz*-q.X-ix*-q.Z, - iz*q.W+iw*-q.Z+ix*-q.Y-iy*-q.X) -} - // Cross returns the cross product of this vector with other. func (v Vector3) Cross(other Vector3) Vector3 { return Vec3(v.Y*other.Z-v.Z*other.Y, v.Z*other.X-v.X*other.Z, v.X*other.Y-v.Y*other.X) diff --git a/xyz/camera.go b/xyz/camera.go index 0b508666ae..f6d6e6ff64 100644 --- a/xyz/camera.go +++ b/xyz/camera.go @@ -155,13 +155,13 @@ func (cm *Camera) Orbit(delX, delY float32) { // delX rotates around the up vector dxq := math32.NewQuatAxisAngle(up, math32.DegToRad(delX)) - dx := ctdir.MulQuat(dxq).Sub(ctdir) + dx := dxq.MulVector(ctdir).Sub(ctdir) // delY rotates around the right vector dyq := math32.NewQuatAxisAngle(right, math32.DegToRad(delY)) - dy := ctdir.MulQuat(dyq).Sub(ctdir) + dy := dyq.MulVector(ctdir).Sub(ctdir) cm.Pose.Pos = cm.Pose.Pos.Add(dx).Add(dy) - cm.UpDir = cm.UpDir.MulQuat(dyq) // this is only one that affects up + cm.UpDir = dyq.MulVector(cm.UpDir) // this is only one that affects up cm.LookAtTarget() } @@ -171,8 +171,8 @@ func (cm *Camera) Orbit(delX, delY float32) { // current window view) // and it moves the target by the same increment, changing the target position. func (cm *Camera) Pan(delX, delY float32) { - dx := math32.Vec3(-delX, 0, 0).MulQuat(cm.Pose.Quat) - dy := math32.Vec3(0, -delY, 0).MulQuat(cm.Pose.Quat) + dx := cm.Pose.Quat.MulVector(math32.Vec3(-delX, 0, 0)) + dy := cm.Pose.Quat.MulVector(math32.Vec3(0, -delY, 0)) td := dx.Add(dy) cm.Pose.Pos.SetAdd(td) cm.Target.SetAdd(td) diff --git a/xyz/pose.go b/xyz/pose.go index b12d45872c..be7cb36988 100644 --- a/xyz/pose.go +++ b/xyz/pose.go @@ -117,7 +117,7 @@ func (ps *Pose) UpdateMVPMatrix(viewMat, projectionMat *math32.Matrix4) { // MoveOnAxis moves (translates) the specified distance on the specified local axis, // relative to the current rotation orientation. func (ps *Pose) MoveOnAxis(x, y, z, dist float32) { - ps.Pos.SetAdd(math32.Vec3(x, y, z).Normal().MulQuat(ps.Quat).MulScalar(dist)) + ps.Pos.SetAdd(ps.Quat.MulVector(math32.Vec3(x, y, z).Normal()).MulScalar(dist)) } // MoveOnAxisAbs moves (translates) the specified distance on the specified local axis, From 04eecbb66cf86b339fa17047f89c12700ed0731b Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 15 Dec 2025 12:40:38 +0100 Subject: [PATCH 79/99] pdf: previous had simpler implementation of MulVector -- this removes the commentary about changes. --- math32/quaternion.go | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/math32/quaternion.go b/math32/quaternion.go index 8dd8b14477..5b3943b7af 100644 --- a/math32/quaternion.go +++ b/math32/quaternion.go @@ -304,34 +304,6 @@ func (q Quat) Mul(other Quat) Quat { // MulVector applies the rotation encoded in the quaternion to the [Vector3]. func (q Quat) MulVector(v Vector3) Vector3 { - // note: these each produce the same results when q is normalized - // but produce very different results for non-normalized, - // which is presumably not well defined anyway. - // According to IsaacLab, the cross-product version is faster, - // but the old version that it replaced is pretty weird. - // In any case, it is much simpler! - // https://github.com/isaac-sim/IsaacLab/issues/1711 - // https://github.com/isaac-sim/IsaacLab/pull/2129/files - - // original version: - // // calculate quat * vector - // ix := q.W*v.X + q.Y*v.Z - q.Z*v.Y - // iy := q.W*v.Y + q.Z*v.X - q.X*v.Z - // iz := q.W*v.Z + q.X*v.Y - q.Y*v.X - // iw := -q.X*v.X - q.Y*v.Y - q.Z*v.Z - // // calculate result * inverse quat - // return Vec3(ix*q.W+iw*-q.X+iy*-q.Z-iz*-q.Y, - // iy*q.W+iw*-q.Y+iz*-q.X-ix*-q.Z, - // iz*q.W+iw*-q.Z+ix*-q.Y-iy*-q.X) - - // warp version: - // c := 2*q.W*q.W - 1 - // d := 2 * (q.X*v.X + q.Y*v.Y + q.Z*v.Z) - // return Vec3(v.X*c+q.X*d+(q.Y*v.Z-q.Z*v.Y)*q.W*2, - // v.Y*c+q.Y*d+(q.Z*v.X-q.X*v.Z)*q.W*2, - // v.Z*c+q.Z*d+(q.X*v.Y-q.Y*v.X)*q.W*2) - - // isaacLab xyz := Vec3(q.X, q.Y, q.Z) t := xyz.Cross(v).MulScalar(2) return v.Add(t.MulScalar(q.W)).Add(xyz.Cross(t)) From 56630f76cc497a97ef66b2a7a98844c7be85c198 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 19 Dec 2025 08:41:17 +0100 Subject: [PATCH 80/99] pdf: math32 minor cleanup --- math32/matrix3.go | 46 ++++++++++++++++++++++++++------------------ math32/quaternion.go | 38 ++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 33 deletions(-) diff --git a/math32/matrix3.go b/math32/matrix3.go index 8ac6e576ce..94e36322c8 100644 --- a/math32/matrix3.go +++ b/math32/matrix3.go @@ -12,7 +12,8 @@ package math32 import "errors" -// Matrix3 is 3x3 matrix organized internally as column matrix. +// Matrix3 is 3x3 matrix organized internally as column matrix, +// with columns as the inner dimension. type Matrix3 [9]float32 // note: matrix indexes are row,column. matrix dimensions are CxR. @@ -132,35 +133,35 @@ func (m *Matrix3) CopyFrom(src Matrix3) { // MulMatrices sets ths matrix as matrix multiplication a by b (i.e., a*b). func (m *Matrix3) MulMatrices(a, b Matrix3) { a11 := a[0] - a12 := a[3] - a13 := a[6] a21 := a[1] - a22 := a[4] - a23 := a[7] a31 := a[2] + a12 := a[3] + a22 := a[4] a32 := a[5] + a13 := a[6] + a23 := a[7] a33 := a[8] b11 := b[0] - b12 := b[3] - b13 := b[6] b21 := b[1] - b22 := b[4] - b23 := b[7] b31 := b[2] + b12 := b[3] + b22 := b[4] b32 := b[5] + b13 := b[6] + b23 := b[7] b33 := b[8] m[0] = b11*a11 + b12*a21 + b13*a31 - m[3] = b11*a12 + b12*a22 + b13*a32 - m[6] = b11*a13 + b12*a23 + b13*a33 - m[1] = b21*a11 + b22*a21 + b23*a31 - m[4] = b21*a12 + b22*a22 + b23*a32 - m[7] = b21*a13 + b22*a23 + b23*a33 - m[2] = b31*a11 + b32*a21 + b33*a31 + + m[3] = b11*a12 + b12*a22 + b13*a32 + m[4] = b21*a12 + b22*a22 + b23*a32 m[5] = b31*a12 + b32*a22 + b33*a32 + + m[6] = b11*a13 + b12*a23 + b13*a33 + m[7] = b21*a13 + b22*a23 + b23*a33 m[8] = b31*a13 + b32*a23 + b33*a33 } @@ -186,13 +187,13 @@ func (m Matrix3) MulScalar(s float32) Matrix3 { // SetMulScalar multiplies each of this matrix's components by the specified scalar. func (m *Matrix3) SetMulScalar(s float32) { m[0] *= s - m[3] *= s - m[6] *= s m[1] *= s - m[4] *= s - m[7] *= s m[2] *= s + m[3] *= s + m[4] *= s m[5] *= s + m[6] *= s + m[7] *= s m[8] *= s } @@ -211,6 +212,13 @@ func (a Matrix3) MulPoint(v Vector2) Vector2 { return Vec2(tx, ty) } +// MulVector3 multiplies the Vector3 on the right, as a standard matrix multiply. +func (a Matrix3) MulVector3(v Vector3) Vector3 { + return Vec3(a[0]*v.X+a[3]*v.Y+a[6]*v.Z, + a[1]*v.X+a[4]*v.Y+a[7]*v.Z, + a[2]*v.X+a[5]*v.Y+a[8]*v.Z) +} + // MulVector3Array multiplies count vectors (i.e., 3 sequential array values per each increment in count) // in the array starting at start index by this matrix. func (m *Matrix3) MulVector3Array(array []float32, start, count int) { diff --git a/math32/quaternion.go b/math32/quaternion.go index 5b3943b7af..97131593d9 100644 --- a/math32/quaternion.go +++ b/math32/quaternion.go @@ -232,8 +232,8 @@ func (q Quat) Conjugate() Quat { } // Dot returns the dot products of this quaternion with other. -func (q Quat) Dot(other Quat) float32 { - return q.X*other.X + q.Y*other.Y + q.Z*other.Z + q.W*other.W +func (q Quat) Dot(o Quat) float32 { + return q.X*o.X + q.Y*o.Y + q.Z*o.Z + q.W*o.W } // LengthSq returns this quanternion's length squared @@ -292,16 +292,26 @@ func MulQuats(a, b Quat) Quat { } // SetMul sets this quaternion to the multiplication of itself by other. -func (q *Quat) SetMul(other Quat) { - *q = MulQuats(*q, other) +func (q *Quat) SetMul(o Quat) { + *q = MulQuats(*q, o) } // Mul returns returns multiplication of this quaternion with other -func (q Quat) Mul(other Quat) Quat { - q.SetMul(other) +func (q Quat) Mul(o Quat) Quat { + q.SetMul(o) return q } +// MulScalar returns values multiplied by scalar +func (q Quat) MulScalar(s float32) Quat { + return NewQuat(q.X*s, q.Y*s, q.Z*s, q.W*s) +} + +// Add returns values added +func (q Quat) Add(o Quat) Quat { + return NewQuat(q.X+o.X, q.Y+o.Y, q.Z+o.Z, q.W+o.W) +} + // MulVector applies the rotation encoded in the quaternion to the [Vector3]. func (q Quat) MulVector(v Vector3) Vector3 { xyz := Vec3(q.X, q.Y, q.Z) @@ -319,12 +329,12 @@ func (q Quat) MulVectorInverse(v Vector3) Vector3 { // Slerp sets this quaternion to another quaternion which is the // spherically linear interpolation from this quaternion to other using t. -func (q *Quat) Slerp(other Quat, t float32) { +func (q *Quat) Slerp(o Quat, t float32) { if t == 0 { return } if t == 1 { - *q = other + *q = o return } @@ -333,16 +343,16 @@ func (q *Quat) Slerp(other Quat, t float32) { z := q.Z w := q.W - cosHalfTheta := w*other.W + x*other.X + y*other.Y + z*other.Z + cosHalfTheta := w*o.W + x*o.X + y*o.Y + z*o.Z if cosHalfTheta < 0 { - q.W = -other.W - q.X = -other.X - q.Y = -other.Y - q.Z = -other.Z + q.W = -o.W + q.X = -o.X + q.Y = -o.Y + q.Z = -o.Z cosHalfTheta = -cosHalfTheta } else { - *q = other + *q = o } if cosHalfTheta >= 1.0 { From 8cffdc08afa5324bd3fb38a0535960b46db6abbc Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Fri, 19 Dec 2025 08:44:23 +0100 Subject: [PATCH 81/99] pdf: yaegicore update --- yaegicore/basesymbols/cogentcore_org-core-math32.go | 2 ++ yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go | 1 + yaegicore/coresymbols/cogentcore_org-core-core.go | 2 ++ yaegicore/coresymbols/cogentcore_org-core-htmlcore.go | 1 + yaegicore/coresymbols/cogentcore_org-core-paint.go | 1 + 5 files changed, 7 insertions(+) diff --git a/yaegicore/basesymbols/cogentcore_org-core-math32.go b/yaegicore/basesymbols/cogentcore_org-core-math32.go index 0cc87cab56..645aa5d4e3 100644 --- a/yaegicore/basesymbols/cogentcore_org-core-math32.go +++ b/yaegicore/basesymbols/cogentcore_org-core-math32.go @@ -82,6 +82,7 @@ func init() { "Log2": reflect.ValueOf(math32.Log2), "Log2E": reflect.ValueOf(constant.MakeFromLiteral("1.44269504088896340735992468100189213742664595415298593413544940772066427768997545329060870636212628972710992130324953463427359402479619301286929040235571747101382214539290471666532766903401352465152740478515625", token.FLOAT, 0)), "Logb": reflect.ValueOf(math32.Logb), + "Mat3": reflect.ValueOf(math32.Mat3), "Matrix3FromMatrix2": reflect.ValueOf(math32.Matrix3FromMatrix2), "Matrix3FromMatrix4": reflect.ValueOf(math32.Matrix3FromMatrix4), "Matrix3Rotate2D": reflect.ValueOf(math32.Matrix3Rotate2D), @@ -94,6 +95,7 @@ func init() { "MinPos": reflect.ValueOf(math32.MinPos), "Mod": reflect.ValueOf(math32.Mod), "Modf": reflect.ValueOf(math32.Modf), + "MulQuats": reflect.ValueOf(math32.MulQuats), "NaN": reflect.ValueOf(math32.NaN), "NewArrayF32": reflect.ValueOf(math32.NewArrayF32), "NewArrayU32": reflect.ValueOf(math32.NewArrayU32), diff --git a/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go b/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go index e7a527e758..825a464a24 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go +++ b/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go @@ -49,6 +49,7 @@ func init() { "Formats": reflect.ValueOf((*imagex.Formats)(nil)), "JSON": reflect.ValueOf((*imagex.JSON)(nil)), "JSONEncoded": reflect.ValueOf((*imagex.JSONEncoded)(nil)), + "JSRGBA": reflect.ValueOf((*imagex.JSRGBA)(nil)), "TestingT": reflect.ValueOf((*imagex.TestingT)(nil)), "Wrapped": reflect.ValueOf((*imagex.Wrapped)(nil)), diff --git a/yaegicore/coresymbols/cogentcore_org-core-core.go b/yaegicore/coresymbols/cogentcore_org-core-core.go index 00f3b5fafc..e390f901de 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-core.go +++ b/yaegicore/coresymbols/cogentcore_org-core-core.go @@ -21,6 +21,8 @@ import ( func init() { Symbols["cogentcore.org/core/core/core"] = map[string]reflect.Value{ // function, constant and variable definitions + "AddAppSettings": reflect.ValueOf(core.AddAppSettings), + "AddPrinterSettings": reflect.ValueOf(core.AddPrinterSettings), "AllRenderWindows": reflect.ValueOf(&core.AllRenderWindows).Elem(), "AllSettings": reflect.ValueOf(&core.AllSettings).Elem(), "AppAbout": reflect.ValueOf(&core.AppAbout).Elem(), diff --git a/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go b/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go index 7fd2b2a36f..8424337315 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go +++ b/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go @@ -20,6 +20,7 @@ func init() { "HasAttr": reflect.ValueOf(htmlcore.HasAttr), "MDGetAttr": reflect.ValueOf(htmlcore.MDGetAttr), "MDSetAttr": reflect.ValueOf(htmlcore.MDSetAttr), + "MDToHTML": reflect.ValueOf(htmlcore.MDToHTML), "NewContext": reflect.ValueOf(htmlcore.NewContext), "ReadHTML": reflect.ValueOf(htmlcore.ReadHTML), "ReadHTMLString": reflect.ValueOf(htmlcore.ReadHTMLString), diff --git a/yaegicore/coresymbols/cogentcore_org-core-paint.go b/yaegicore/coresymbols/cogentcore_org-core-paint.go index 596fcd9446..67026f45c6 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-paint.go +++ b/yaegicore/coresymbols/cogentcore_org-core-paint.go @@ -18,6 +18,7 @@ func init() { "NewSVGRenderer": reflect.ValueOf(&paint.NewSVGRenderer).Elem(), "NewSourceRenderer": reflect.ValueOf(&paint.NewSourceRenderer).Elem(), "RenderToImage": reflect.ValueOf(paint.RenderToImage), + "RenderToPDF": reflect.ValueOf(paint.RenderToPDF), "RenderToSVG": reflect.ValueOf(paint.RenderToSVG), // type definitions From 234f7e6266c68c55d40163a0814c65df4f0dbd6f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Mon, 22 Dec 2025 10:58:11 +0100 Subject: [PATCH 82/99] pdf: fixes from kai review --- content/content.go | 2 +- content/url_js.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/content.go b/content/content.go index 4647f7e288..44af15555d 100644 --- a/content/content.go +++ b/content/content.go @@ -496,7 +496,7 @@ func (ct *Content) PagePDF(path string) error { fname := ct.currentPage.Name + ".pdf" if path != "" { - os.MkdirAll(path, 0777) + errors.Log(os.MkdirAll(path, 0777)) fname = filepath.Join(path, fname) } f, err := os.Create(fname) diff --git a/content/url_js.go b/content/url_js.go index 0b885b455a..de9a1fd844 100644 --- a/content/url_js.go +++ b/content/url_js.go @@ -28,7 +28,7 @@ var ( func (ct *Content) getPrintURL() string { p := originalBase.String() - return p[:len(p)-1] + return strings.TrimSuffix(p, "/") } // getWebURL returns the current relative web URL that should be passed to [Content.Open] From f9fdeb94941a6401ae6b010498c7922c7536d44c Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 23 Dec 2025 06:54:27 +0100 Subject: [PATCH 83/99] pdf: key fix for xyz on web: need Size method on renderer interface. --- gpu/renderer.go | 3 +++ gpu/rendertexture.go | 4 ++++ gpu/surface.go | 4 ++++ xyz/render.go | 4 ++-- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/gpu/renderer.go b/gpu/renderer.go index bcb32fd806..ef295bff18 100644 --- a/gpu/renderer.go +++ b/gpu/renderer.go @@ -38,6 +38,9 @@ type Renderer interface { // This is efficient to call if already the same size. SetSize(size image.Point) + // Size returns the current render image size. + Size() image.Point + // Release frees associated GPU resources. Release() } diff --git a/gpu/rendertexture.go b/gpu/rendertexture.go index 17195d2d21..5b6ecc2cd2 100644 --- a/gpu/rendertexture.go +++ b/gpu/rendertexture.go @@ -117,6 +117,10 @@ func (rt *RenderTexture) SetSize(size image.Point) { rt.ConfigFrames() } +func (rt *RenderTexture) Size() image.Point { + return rt.Format.Bounds().Size() +} + func (rt *RenderTexture) ReleaseFrames() { for _, fr := range rt.Frames { fr.Release() diff --git a/gpu/surface.go b/gpu/surface.go index bfb8e3effb..7c5b469d45 100644 --- a/gpu/surface.go +++ b/gpu/surface.go @@ -102,6 +102,10 @@ func (sf *Surface) SetSize(sz image.Point) { sf.Reconfig() } +func (sf *Surface) Size() image.Point { + return sf.Format.Bounds().Size() +} + // GetCurrentTexture returns a TextureView that is the current // target for rendering. func (sf *Surface) GetCurrentTexture() (*wgpu.TextureView, error) { diff --git a/xyz/render.go b/xyz/render.go index 6428dbf351..b0d2ae1cd2 100644 --- a/xyz/render.go +++ b/xyz/render.go @@ -132,9 +132,9 @@ func (sc *Scene) RenderGrabImage() *image.NRGBA { func (sc *Scene) render(grabImage bool) *image.NRGBA { sc.Lock() defer sc.Unlock() - tex := sc.Phong.System.Renderer.(*gpu.RenderTexture) + tex := sc.Phong.System.Renderer sc.Phong.System.SetClearColor(sc.Background.At(0, 0)) - sc.Camera.SetAspect(tex.Format.Bounds().Size()) + sc.Camera.SetAspect(tex.Size()) if len(sc.SavedCams) == 0 { sc.SaveCamera("default") From 7abcf2501544c046df1936f841e46b025e8e1a3d Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 23 Dec 2025 21:48:58 +0100 Subject: [PATCH 84/99] pdf: how can I still be so confused about rows, columns in matrix after all these years! --- math32/matrix2.go | 2 +- math32/matrix3.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/math32/matrix2.go b/math32/matrix2.go index 533c495b32..c0f00a3033 100644 --- a/math32/matrix2.go +++ b/math32/matrix2.go @@ -39,7 +39,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Matrix2 is a 2x3 matrix. +// Matrix2 is a 3x2 matrix. // [XX YX] // [XY YY] // [X0 Y0] diff --git a/math32/matrix3.go b/math32/matrix3.go index 94e36322c8..f2b69836e6 100644 --- a/math32/matrix3.go +++ b/math32/matrix3.go @@ -16,8 +16,7 @@ import "errors" // with columns as the inner dimension. type Matrix3 [9]float32 -// note: matrix indexes are row,column. matrix dimensions are CxR. -// nothing like consistency... +// note: matrix indexes and dimensions are row,column. // Mat3 constructs a new Matrix3 from given values, in column-wise order. func Mat3(n11, n21, n31, n12, n22, n32, n13, n23, n33 float32) Matrix3 { From c5dbfaa3599c0235785c154badba99f56745221d Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 23 Dec 2025 22:37:33 +0100 Subject: [PATCH 85/99] pdf: remove matcolor.Scheme.Background in favor of Surface, OnSurface --- colors/matcolor/scheme.go | 12 ------------ core/render_notjs.go | 4 ++-- core/scene.go | 4 ++-- core/tabs.go | 2 +- gpu/gpudraw/drawer.go | 2 +- gpu/gsystem.go | 2 +- styles/css.go | 2 +- 7 files changed, 8 insertions(+), 20 deletions(-) diff --git a/colors/matcolor/scheme.go b/colors/matcolor/scheme.go index 30f0c2905a..5b7c728ae2 100644 --- a/colors/matcolor/scheme.go +++ b/colors/matcolor/scheme.go @@ -81,12 +81,6 @@ type Scheme struct { // InversePrimary is the color applied to interactive elements on top of InverseSurface InversePrimary image.Image - // Background is the color applied to the background of the app and other low-emphasis areas - Background image.Image - - // OnBackground is the color applied to content on top of Background - OnBackground image.Image - // Outline is the color applied to borders to create emphasized boundaries that need to have sufficient contrast Outline image.Image @@ -134,9 +128,6 @@ func NewLightScheme(p *Palette) Scheme { InverseOnSurface: p.Neutral.AbsToneUniform(95), InversePrimary: p.Primary.AbsToneUniform(80), - Background: p.Neutral.AbsToneUniform(100), - OnBackground: p.Neutral.AbsToneUniform(10), - Outline: p.NeutralVariant.AbsToneUniform(50), OutlineVariant: p.NeutralVariant.AbsToneUniform(80), @@ -182,9 +173,6 @@ func NewDarkScheme(p *Palette) Scheme { InverseOnSurface: p.Neutral.AbsToneUniform(20), InversePrimary: p.Primary.AbsToneUniform(40), - Background: p.Neutral.AbsToneUniform(0), - OnBackground: p.Neutral.AbsToneUniform(90), - Outline: p.NeutralVariant.AbsToneUniform(60), OutlineVariant: p.NeutralVariant.AbsToneUniform(30), diff --git a/core/render_notjs.go b/core/render_notjs.go index c03fb03b41..169f61e31f 100644 --- a/core/render_notjs.go +++ b/core/render_notjs.go @@ -60,7 +60,7 @@ type fillInsetsSource struct { func (ss *fillInsetsSource) Draw(c composer.Composer) { cd := c.(*composer.ComposerDrawer) - clr := colors.Scheme.Background + clr := colors.Scheme.Surface fill := func(x0, y0, x1, y1 int) { r := image.Rect(x0, y0, x1, y1) @@ -77,7 +77,7 @@ func (ss *fillInsetsSource) Draw(c composer.Composer) { fill(0, 0, rb.Min.X, wb.Max.Y) // left } -// fillInsets fills the window insets, if any, with [colors.Scheme.Background]. +// fillInsets fills the window insets, if any, with [colors.Scheme.Surface]. func (w *renderWindow) fillInsets(cp composer.Composer) { // render geom and window geom rg := w.SystemWindow.RenderGeom() diff --git a/core/scene.go b/core/scene.go index 40fd23d10f..a5eb283155 100644 --- a/core/scene.go +++ b/core/scene.go @@ -185,8 +185,8 @@ func (sc *Scene) Init() { sc.Styler(func(s *styles.Style) { s.SetAbilities(true, abilities.Clickable) // this is critical to enable click-off to turn off focus. s.Cursor = cursors.Arrow - s.Background = colors.Scheme.Background - s.Color = colors.Scheme.OnBackground + s.Background = colors.Scheme.Surface + s.Color = colors.Scheme.OnSurface // we never want borders on scenes s.MaxBorder = styles.Border{} s.Direction = styles.Column diff --git a/core/tabs.go b/core/tabs.go index 2ec2d54db2..4e5d4341e3 100644 --- a/core/tabs.go +++ b/core/tabs.go @@ -114,7 +114,7 @@ func (ts *Tabs) Init() { ts.maxChars = 16 ts.CloseIcon = icons.Close ts.Styler(func(s *styles.Style) { - s.Color = colors.Scheme.OnBackground + s.Color = colors.Scheme.OnSurface s.Grow.Set(1, 1) if ts.Type.effective(ts).isColumn() { s.Direction = styles.Row diff --git a/gpu/gpudraw/drawer.go b/gpu/gpudraw/drawer.go index 15a433aea4..3978e09f70 100644 --- a/gpu/gpudraw/drawer.go +++ b/gpu/gpudraw/drawer.go @@ -118,7 +118,7 @@ func (dw *Drawer) Start() { defer dw.Unlock() // always use the default background color for clearing in general - dw.System.SetClearColor(colors.ToUniform(colors.Scheme.Background)) + dw.System.SetClearColor(colors.ToUniform(colors.Scheme.Surface)) dw.opList = dw.opList[:0] dw.images.start() diff --git a/gpu/gsystem.go b/gpu/gsystem.go index 98a84e88e3..e6aec7ff68 100644 --- a/gpu/gsystem.go +++ b/gpu/gsystem.go @@ -129,7 +129,7 @@ func (sy *GraphicsSystem) SetGraphicsDefaults() *GraphicsSystem { for _, pl := range sy.GraphicsPipelines { pl.SetGraphicsDefaults() } - sy.SetClearColor(colors.ToUniform(colors.Scheme.Background)) + sy.SetClearColor(colors.ToUniform(colors.Scheme.Surface)) sy.SetClearDepthStencil(1, 0) return sy } diff --git a/styles/css.go b/styles/css.go index 12a7641ab1..c107b9d33d 100644 --- a/styles/css.go +++ b/styles/css.go @@ -103,7 +103,7 @@ func colorToCSS(c image.Image) string { return "var(--secondary-container-color)" case colors.Scheme.Secondary.OnContainer: return "var(--secondary-on-container-color)" - case colors.Scheme.Surface, colors.Scheme.OnSurface, colors.Scheme.Background, colors.Scheme.OnBackground: + case colors.Scheme.Surface, colors.Scheme.OnSurface: return "" // already default case colors.Scheme.SurfaceContainer, colors.Scheme.SurfaceContainerLowest, colors.Scheme.SurfaceContainerLow, colors.Scheme.SurfaceContainerHigh, colors.Scheme.SurfaceContainerHighest: return "var(--surface-container-color)" // all of them are close enough for this From 0f5920afb221d829b22adcbba974ed9f7d02efc0 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 24 Dec 2025 21:11:49 +0100 Subject: [PATCH 86/99] pdf: ZeroSpace() also nils BoxShadow and MaxBoxShadow --- styles/style.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/styles/style.go b/styles/style.go index e731de8358..e44a1a1489 100644 --- a/styles/style.go +++ b/styles/style.go @@ -255,6 +255,8 @@ func (s *Style) ZeroSpace() { s.MaxBorder.Width.Zero() s.Border.Width.Zero() s.Gap.Zero() + s.BoxShadow = nil + s.MaxBoxShadow = nil } // VirtualKeyboards are all of the supported virtual keyboard types From 214d0d9b58824423fa1076f0b4c7b1d38458ab76 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 24 Dec 2025 21:18:06 +0100 Subject: [PATCH 87/99] pdf: locale simplification --- system/locale.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/system/locale.go b/system/locale.go index 0ae52e8191..e94cf675ca 100644 --- a/system/locale.go +++ b/system/locale.go @@ -12,14 +12,8 @@ type Locale string // Language returns the language portion of the locale tag (e.g., en, fr, ja) func (l Locale) Language() string { - if l == "" { - return "" - } - pos := strings.LastIndex(string(l), "-") - if pos < 0 { - return string(l) - } - return string(l)[:pos] + lang, _, _ := strings.Cut(string(l), "-") + return lang } // Region returns the region portion of the locale tag (e.g., US, FR, JA) From a7fc614606e1e0864a137b8ef2a161c064288558 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 24 Dec 2025 21:19:23 +0100 Subject: [PATCH 88/99] pdf: locale simplification p2 --- system/locale.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/system/locale.go b/system/locale.go index e94cf675ca..dc63c1caf9 100644 --- a/system/locale.go +++ b/system/locale.go @@ -18,12 +18,6 @@ func (l Locale) Language() string { // Region returns the region portion of the locale tag (e.g., US, FR, JA) func (l Locale) Region() string { - if l == "" { - return "" - } - pos := strings.LastIndex(string(l), "-") - if pos < 0 { - return "" - } - return string(l)[pos+1:] + _, region, _ := strings.Cut(string(l), "-") + return region } From 641c747c109a78e4d3bf1fa330217ea2a746c833 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 24 Dec 2025 21:21:45 +0100 Subject: [PATCH 89/99] pdf: mdcite separator --- text/csl/mdcite/mdcite.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/csl/mdcite/mdcite.go b/text/csl/mdcite/mdcite.go index 0fed65513f..8f805edb18 100644 --- a/text/csl/mdcite/mdcite.go +++ b/text/csl/mdcite/mdcite.go @@ -52,7 +52,7 @@ func Generate(c *Config) error { kl := csl.NewKeyList(refs) if c.Dir == "" { - c.Dir = "./" + c.Dir = "." + string(filepath.Separator) } mds := fsx.Filenames(c.Dir, ".md") if len(mds) == 0 { From adcbf182f8f56b60709bdb5b72ed25c1f81d0cab Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 24 Dec 2025 21:29:13 +0100 Subject: [PATCH 90/99] pdf: other review fixes --- text/paginate/paginate_test.go | 2 -- text/paginate/runners.go | 2 +- text/shaped/shaped_test.go | 1 + 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/text/paginate/paginate_test.go b/text/paginate/paginate_test.go index 2b979df6bb..8a0feed63e 100644 --- a/text/paginate/paginate_test.go +++ b/text/paginate/paginate_test.go @@ -14,7 +14,6 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/events" "cogentcore.org/core/styles" - "cogentcore.org/core/text/rich" ) // RunTest runs a test for given test case. @@ -29,7 +28,6 @@ func RunTest(t *testing.T, nm string, f func() *core.Body) { <-showed opts := NewOptions() - opts.FontFamily = rich.Serif opts.Header = NoFirst(HeaderLeftPageNumber("This is a test header")) opts.Title = CenteredTitle("This is a Profound Statement of Something Important", "Bea A. Author", "University of Twente
Department of Physiology", "March 1, 2024", `https://example.com/testing`, "The thing about this paper is that it is dealing with an issue that should be given more attention, but perhaps it really is hard to understand and that makes it difficult to get the attention it deserves. In any case, we are very proud.") buff := bytes.Buffer{} diff --git a/text/paginate/runners.go b/text/paginate/runners.go index 10981d7180..124147e8bb 100644 --- a/text/paginate/runners.go +++ b/text/paginate/runners.go @@ -23,7 +23,7 @@ func TextStyler(s *styles.Style) { s.Color = colors.Uniform(colors.Black) } -// CenteredPageNumber generates a page number cenetered in the frame +// CenteredPageNumber generates a page number centered in the frame // with a 1.5em space above it. func CenteredPageNumber(frame *core.Frame, opts *Options, pageNo int) { core.NewSpace(frame).Styler(func(s *styles.Style) { // space before diff --git a/text/shaped/shaped_test.go b/text/shaped/shaped_test.go index 4e735ead4b..f2b14367f4 100644 --- a/text/shaped/shaped_test.go +++ b/text/shaped/shaped_test.go @@ -169,6 +169,7 @@ func TestHebrew(t *testing.T) { func TestVertical(t *testing.T) { RunTest(t, "nihongo_ttb", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style) { + // these are correctly inferred: // rts.Language = "ja" // rts.Script = language.Han tsty.Direction = rich.TTB // rich.BTT // note: apparently BTT is actually never used From abc39064b658833a22e36e73f34703cf99be385a Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 24 Dec 2025 22:37:09 +0100 Subject: [PATCH 91/99] pdf: always add printer settings, in core settings; also fix issue with margins in printer settings. --- colors/matcolor/scheme.go | 2 +- content/settings.go | 2 -- core/settings.go | 7 +------ system/driver/base/app.go | 2 -- text/printer/settings.go | 1 + yaegicore/coresymbols/cogentcore_org-core-core.go | 1 - 6 files changed, 3 insertions(+), 12 deletions(-) diff --git a/colors/matcolor/scheme.go b/colors/matcolor/scheme.go index 5b7c728ae2..0b841fff73 100644 --- a/colors/matcolor/scheme.go +++ b/colors/matcolor/scheme.go @@ -111,7 +111,7 @@ func NewLightScheme(p *Palette) Scheme { Custom: map[string]Accent{}, SurfaceDim: p.Neutral.AbsToneUniform(87), - Surface: p.Neutral.AbsToneUniform(99), + Surface: p.Neutral.AbsToneUniform(100), SurfaceBright: p.Neutral.AbsToneUniform(99), SurfaceContainerLowest: p.Neutral.AbsToneUniform(100), diff --git a/content/settings.go b/content/settings.go index 9abe2bc62b..9ffe6fb3ba 100644 --- a/content/settings.go +++ b/content/settings.go @@ -8,12 +8,10 @@ import ( "time" "cogentcore.org/core/content/bcontent" - "cogentcore.org/core/core" "cogentcore.org/core/text/paginate" ) func init() { - core.AddPrinterSettings() Settings.Defaults() } diff --git a/core/settings.go b/core/settings.go index 9e427a2ede..06e0601085 100644 --- a/core/settings.go +++ b/core/settings.go @@ -38,18 +38,13 @@ import ( // that the user will see in the settings window. It contains the base Cogent Core // settings by default and should be modified by other apps to add their // app settings. -var AllSettings = []Settings{AppearanceSettings, SystemSettings, TimingSettings, DebugSettings} +var AllSettings = []Settings{AppearanceSettings, SystemSettings, &printer.Settings, TimingSettings, DebugSettings} // AddAppSettings inserts app-specific settings after the AppearanceSettings func AddAppSettings(sets ...Settings) { AllSettings = slices.Insert(AllSettings, 1, sets...) } -// AddPrinterSettings adds printer settings, for apps that use these. -func AddPrinterSettings() { - AddAppSettings(&printer.Settings) -} - // Settings is the interface that describes the functionality common // to all settings data types. type Settings interface { diff --git a/system/driver/base/app.go b/system/driver/base/app.go index 10c3e34f2f..99a9599ea8 100644 --- a/system/driver/base/app.go +++ b/system/driver/base/app.go @@ -20,7 +20,6 @@ import ( "cogentcore.org/core/events/key" "cogentcore.org/core/styles" "cogentcore.org/core/system" - "cogentcore.org/core/text/printer" "github.com/jeandeaual/go-locale" ) @@ -71,7 +70,6 @@ func Init(a system.App, ab *App) { key.SystemPlatform = a.SystemPlatform().String() // sl := a.SystemLocale() // fmt.Println("locale:", sl, sl.Language(), sl.Region()) - printer.Settings.Defaults() // depends on system.TheApp } func (a *App) MainLoop() { diff --git a/text/printer/settings.go b/text/printer/settings.go index b1c7f598e5..f626b92656 100644 --- a/text/printer/settings.go +++ b/text/printer/settings.go @@ -57,6 +57,7 @@ type SettingsData struct { func (ps *SettingsData) Defaults() { ps.PageSize = DefaultPageSizeForRegion(system.TheApp.SystemLocale().Region()) + ps.Update() switch ps.Units { case units.UnitMm: ps.Margins.Set(25) // basically one inch diff --git a/yaegicore/coresymbols/cogentcore_org-core-core.go b/yaegicore/coresymbols/cogentcore_org-core-core.go index e390f901de..5bdac55c2f 100644 --- a/yaegicore/coresymbols/cogentcore_org-core-core.go +++ b/yaegicore/coresymbols/cogentcore_org-core-core.go @@ -22,7 +22,6 @@ func init() { Symbols["cogentcore.org/core/core/core"] = map[string]reflect.Value{ // function, constant and variable definitions "AddAppSettings": reflect.ValueOf(core.AddAppSettings), - "AddPrinterSettings": reflect.ValueOf(core.AddPrinterSettings), "AllRenderWindows": reflect.ValueOf(&core.AllRenderWindows).Elem(), "AllSettings": reflect.ValueOf(&core.AllSettings).Elem(), "AppAbout": reflect.ValueOf(&core.AppAbout).Elem(), From 92082d01fd3f4f3a7222d8fd8bcd1de06485f330 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 30 Dec 2025 09:21:44 +0100 Subject: [PATCH 92/99] pdf: add NewQuatIdentity function --- math32/quaternion.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/math32/quaternion.go b/math32/quaternion.go index 97131593d9..e939b216ce 100644 --- a/math32/quaternion.go +++ b/math32/quaternion.go @@ -25,6 +25,11 @@ func NewQuat(x, y, z, w float32) Quat { return Quat{X: x, Y: y, Z: z, W: w} } +// NewQuatIdentity returns a new quaternion with the identity rotation. +func NewQuatIdentity() Quat { + return Quat{X: 0, Y: 0, Z: 0, W: 1} +} + // NewQuatAxisAngle returns a new quaternion from given axis and angle rotation (radians). func NewQuatAxisAngle(axis Vector3, angle float32) Quat { nq := Quat{} From 8c1c3d46495caee65e68969bfc9a5cee5c70f626 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 30 Dec 2025 09:22:20 +0100 Subject: [PATCH 93/99] pdf: yaegi update --- yaegicore/basesymbols/cogentcore_org-core-math32.go | 1 + 1 file changed, 1 insertion(+) diff --git a/yaegicore/basesymbols/cogentcore_org-core-math32.go b/yaegicore/basesymbols/cogentcore_org-core-math32.go index 645aa5d4e3..e7c96a54ad 100644 --- a/yaegicore/basesymbols/cogentcore_org-core-math32.go +++ b/yaegicore/basesymbols/cogentcore_org-core-math32.go @@ -109,6 +109,7 @@ func init() { "NewQuat": reflect.ValueOf(math32.NewQuat), "NewQuatAxisAngle": reflect.ValueOf(math32.NewQuatAxisAngle), "NewQuatEuler": reflect.ValueOf(math32.NewQuatEuler), + "NewQuatIdentity": reflect.ValueOf(math32.NewQuatIdentity), "NewRay": reflect.ValueOf(math32.NewRay), "NewSphere": reflect.ValueOf(math32.NewSphere), "NewTriangle": reflect.ValueOf(math32.NewTriangle), From 665c5f304b02ef5c5e901d209ca6cd082d53ae0f Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Wed, 31 Dec 2025 09:25:51 +0100 Subject: [PATCH 94/99] pdf: add WrapMax and WrapMinMax functions for efficient wrapping (finally), including WrapPi convenience function, along with associated tests, including for previously untested Truncate and IntMultiple functions. --- math32/math.go | 20 ++- math32/math_test.go | 147 ++++++++++++++++++ .../basesymbols/cogentcore_org-core-math32.go | 3 + 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 math32/math_test.go diff --git a/math32/math.go b/math32/math.go index b67e02150e..d4f7aa97ec 100644 --- a/math32/math.go +++ b/math32/math.go @@ -780,8 +780,7 @@ func Yn(n int, x float32) float32 { return math32.Yn(n, x) } -////////////////////////////////////////////////////////////// -// Special additions to math. functions +//////// Special additions to math. functions // Clamp clamps x to the provided closed interval [a, b] func Clamp[T cmp.Ordered](x, a, b T) T { @@ -853,3 +852,20 @@ func Truncate64(val float64, prec int) float64 { // pow := math.Pow(10, float64(prec)) // return math.Round(val*pow) / pow } + +// WrapsMax returns x wrapped in the interval [0..max). +func WrapMax(x, mx float32) float32 { + // integer math: (max + x % max) % max + return Mod(mx+Mod(x, mx), mx) +} + +// WrapMinMax returns x wrapped in the interval [min..max). +func WrapMinMax(x, mn, mx float32) float32 { + return mn + WrapMax(x-mn, mx-mn) +} + +// WrapPi wraps the value into the [-Pi, Pi) interval. +func WrapPi(x float32) float32 { + w := WrapMinMax(x, -Pi, Pi) + return w +} diff --git a/math32/math_test.go b/math32/math_test.go new file mode 100644 index 0000000000..f90a24baef --- /dev/null +++ b/math32/math_test.go @@ -0,0 +1,147 @@ +// Copyright (c) 2024, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package math32 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTruncate(t *testing.T) { + tests := []struct { + x float32 + prec int + cor float32 + }{ + {x: Pi, prec: 1, cor: 3}, + {x: Pi, prec: 2, cor: 3.1}, + {x: Pi, prec: 3, cor: 3.14}, + {x: Pi, prec: 4, cor: 3.142}, + {x: Pi, prec: 5, cor: 3.1416}, + {x: Pi, prec: 6, cor: 3.14159}, + {x: Pi, prec: 7, cor: 3.141593}, + } + for _, tt := range tests { + got := Truncate(tt.x, tt.prec) + assert.Equal(t, tt.cor, got) + } +} + +func TestTruncate64(t *testing.T) { + tests := []struct { + x float64 + prec int + cor float64 + }{ + {x: Pi, prec: 1, cor: 3}, + {x: Pi, prec: 2, cor: 3.1}, + {x: Pi, prec: 3, cor: 3.14}, + {x: Pi, prec: 4, cor: 3.142}, + {x: Pi, prec: 5, cor: 3.1416}, + {x: Pi, prec: 6, cor: 3.14159}, + {x: Pi, prec: 7, cor: 3.141593}, + } + for _, tt := range tests { + got := Truncate64(tt.x, tt.prec) + assert.Equal(t, tt.cor, got) + } +} + +func TestIntMultiple(t *testing.T) { + tests := []struct { + x, mod, cor float32 + }{ + {x: 2, mod: 1, cor: 2}, + {x: 2.1, mod: 1, cor: 2}, + {x: 2.4, mod: 1, cor: 2}, + {x: 2.5, mod: 1, cor: 3}, + {x: 2.9, mod: 1, cor: 3}, + {x: 1000, mod: 50, cor: 1000}, + {x: 1005, mod: 50, cor: 1000}, + {x: 1020, mod: 50, cor: 1000}, + {x: 1025, mod: 50, cor: 1050}, + {x: 1049, mod: 50, cor: 1050}, + } + for _, tt := range tests { + got := IntMultiple(tt.x, tt.mod) + assert.Equal(t, tt.cor, got) + } +} + +func TestIntMultipleGE(t *testing.T) { + tests := []struct { + x, mod, cor float32 + }{ + {x: 2, mod: 1, cor: 2}, + {x: 2.1, mod: 1, cor: 3}, + {x: 2.4, mod: 1, cor: 3}, + {x: 2.5, mod: 1, cor: 3}, + {x: 2.9, mod: 1, cor: 3}, + {x: 1000, mod: 50, cor: 1000}, + {x: 1005, mod: 50, cor: 1050}, + {x: 1020, mod: 50, cor: 1050}, + {x: 1025, mod: 50, cor: 1050}, + {x: 1049, mod: 50, cor: 1050}, + } + for _, tt := range tests { + got := IntMultipleGE(tt.x, tt.mod) + assert.Equal(t, tt.cor, got) + } +} + +func TestWrapMax(t *testing.T) { + tests := []struct { + x, mx, cor float32 + }{ + {x: 2, mx: 1, cor: 0}, + {x: 2.5, mx: 2, cor: 0.5}, + {x: 10002.5, mx: 2, cor: 0.5}, + {x: -2.5, mx: 2, cor: 1.5}, + {x: -200.5, mx: 2, cor: 1.5}, + {x: 3.14, mx: 3.1, cor: 0.04}, + } + for _, tt := range tests { + got := WrapMax(tt.x, tt.mx) + assert.InDelta(t, tt.cor, got, 1e-5) + } +} + +func TestWrapMinMax(t *testing.T) { + tests := []struct { + x, mn, mx, cor float32 + }{ + {x: 2, mn: -1, mx: 1, cor: 0}, + {x: 2.5, mn: -2, mx: 2, cor: -1.5}, + {x: 10002.5, mn: -2, mx: 2, cor: -1.5}, + {x: -2.5, mn: -2, mx: 2, cor: 1.5}, + {x: -200.5, mn: -2, mx: 2, cor: -0.5}, + {x: 3.14, mn: -3.1, mx: 3.1, cor: -3.06}, + } + for _, tt := range tests { + got := WrapMinMax(tt.x, tt.mn, tt.mx) + assert.InDelta(t, tt.cor, got, 1e-5) + } +} + +func TestWrapPi(t *testing.T) { + tests := []struct { + x, cor float32 + }{ + {x: 0, cor: 0}, + {x: Pi, cor: -Pi}, + {x: -Pi, cor: -Pi}, + {x: 3 * Pi, cor: -Pi}, + {x: -3 * Pi, cor: -Pi}, + {x: 4 * Pi, cor: 0}, + {x: 0.5 * Pi, cor: 0.5 * Pi}, + {x: -0.5 * Pi, cor: -0.5 * Pi}, + {x: 2 * Pi, cor: 0}, + } + for _, tt := range tests { + got := WrapPi(tt.x) + assert.InDelta(t, tt.cor, got, 1e-5) + } +} diff --git a/yaegicore/basesymbols/cogentcore_org-core-math32.go b/yaegicore/basesymbols/cogentcore_org-core-math32.go index e7c96a54ad..6d853916c9 100644 --- a/yaegicore/basesymbols/cogentcore_org-core-math32.go +++ b/yaegicore/basesymbols/cogentcore_org-core-math32.go @@ -177,6 +177,9 @@ func init() { "Vector4FromVector3": reflect.ValueOf(math32.Vector4FromVector3), "Vector4Scalar": reflect.ValueOf(math32.Vector4Scalar), "W": reflect.ValueOf(math32.W), + "WrapMax": reflect.ValueOf(math32.WrapMax), + "WrapMinMax": reflect.ValueOf(math32.WrapMinMax), + "WrapPi": reflect.ValueOf(math32.WrapPi), "X": reflect.ValueOf(math32.X), "Y": reflect.ValueOf(math32.Y), "Y0": reflect.ValueOf(math32.Y0), From ac6d590e4e1c301bd63577910a2fd54b93cd2af3 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Thu, 1 Jan 2026 09:05:46 +0100 Subject: [PATCH 95/99] pdf: handle case that occurs in new text in canvas where stroke = none gets set as fully transparent color, in which case text render mode should not render stroke. --- paint/pdf/text.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/paint/pdf/text.go b/paint/pdf/text.go index a2fe929299..26ee83172e 100644 --- a/paint/pdf/text.go +++ b/paint/pdf/text.go @@ -175,10 +175,19 @@ func (r *PDF) textRun(style *styles.Paint, m math32.Matrix2, run *shapedgt.Run, } } -func (r *PDF) setTextStrokeColor(clr image.Image) { +// setTextStrokeColor sets the text stroke color. Returns false +// if stroke color is fully transparent and thus should not be rendered. +func (r *PDF) setTextStrokeColor(clr image.Image) bool { sc := r.w.style().Stroke + if x, ok := clr.(*image.Uniform); ok { + c := colors.ApplyOpacity(colors.AsRGBA(x), sc.Opacity) + if c.A == 0 { // fully transparent + return false + } + } sc.Color = clr r.w.SetStroke(&sc) + return true } func (r *PDF) setTextFillColor(clr image.Image) { @@ -193,16 +202,17 @@ func (r *PDF) setTextStyle(fnt *text.Font, style *styles.Paint, fill, stroke ima sty := fnt.Style(tsty) r.w.SetFont(sty, tsty) mode := 0 + hasStroke := false if stroke != nil { - r.setTextStrokeColor(stroke) + hasStroke = r.setTextStrokeColor(stroke) } if fill != nil { r.setTextFillColor(fill) - if stroke != nil { + if hasStroke { mode = 2 } } else { - if stroke != nil { + if hasStroke { mode = 1 } } From 166ced6437750b0174c533b8cba00c82ecb53416 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 4 Jan 2026 07:24:34 -0800 Subject: [PATCH 96/99] pdf: math32 additions: IsNaN() methods for Vector, Quat for finding bugs, and MinAngleDiff for subtracting angles. --- math32/math.go | 13 +++++++++++++ math32/math_test.go | 16 ++++++++++++++++ math32/quaternion.go | 4 ++++ math32/vector3.go | 4 ++++ 4 files changed, 37 insertions(+) diff --git a/math32/math.go b/math32/math.go index d4f7aa97ec..7e5fca01d4 100644 --- a/math32/math.go +++ b/math32/math.go @@ -869,3 +869,16 @@ func WrapPi(x float32) float32 { w := WrapMinMax(x, -Pi, Pi) return w } + +// MinAngleDiff returns the minimum difference between two angles +// (in radians): a-b, dealing with the wrap-around issues with angles. +func MinAngleDiff(a, b float32) float32 { + d := WrapPi(a) - WrapPi(b) + if d > Pi { + d -= 2 * Pi + } + if d < -Pi { + d += 2 * Pi + } + return d +} diff --git a/math32/math_test.go b/math32/math_test.go index f90a24baef..9ec6bebca5 100644 --- a/math32/math_test.go +++ b/math32/math_test.go @@ -145,3 +145,19 @@ func TestWrapPi(t *testing.T) { assert.InDelta(t, tt.cor, got, 1e-5) } } + +func TestMinAngleDiff(t *testing.T) { + tests := []struct { + a, b, cor float32 + }{ + {a: 0, b: Pi, cor: Pi}, + {a: Pi, b: -Pi - 0.5, cor: 0.5}, + {a: -Pi, b: Pi + 0.5, cor: -0.5}, + {a: Pi, b: -Pi, cor: 0}, + {a: Pi, b: -Pi + 0.1, cor: -0.1}, + } + for _, tt := range tests { + got := MinAngleDiff(tt.a, tt.b) + assert.InDelta(t, tt.cor, got, 1e-5) + } +} diff --git a/math32/quaternion.go b/math32/quaternion.go index e939b216ce..e9b377a192 100644 --- a/math32/quaternion.go +++ b/math32/quaternion.go @@ -86,6 +86,10 @@ func (q Quat) IsNil() bool { return q.X == 0 && q.Y == 0 && q.Z == 0 && q.W == 0 } +func (q Quat) IsNaN() bool { + return IsNaN(q.X) || IsNaN(q.Y) || IsNaN(q.Z) || IsNaN(q.W) +} + func (q Quat) String() string { return fmt.Sprintf("(%v, %v, %v, %v)", q.X, q.Y, q.Z, q.W) } diff --git a/math32/vector3.go b/math32/vector3.go index ca5255a2a5..0d1e34d0d2 100644 --- a/math32/vector3.go +++ b/math32/vector3.go @@ -120,6 +120,10 @@ func (v Vector3) ToSlice(array []float32, offset int) { array[offset+2] = v.Z } +func (v Vector3) IsNaN() bool { + return IsNaN(v.X) || IsNaN(v.Y) || IsNaN(v.Z) +} + // Basic math operations: // Add adds the other given vector to this one and returns the result as a new vector. From 413c6cdb5c603c64b71912534e3d83a214963a86 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 4 Jan 2026 07:26:50 -0800 Subject: [PATCH 97/99] pdf: Surface color for dark theme is 0, like the now-removed Background value. --- colors/matcolor/scheme.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/colors/matcolor/scheme.go b/colors/matcolor/scheme.go index 0b841fff73..1976ee5094 100644 --- a/colors/matcolor/scheme.go +++ b/colors/matcolor/scheme.go @@ -156,7 +156,7 @@ func NewDarkScheme(p *Palette) Scheme { Custom: map[string]Accent{}, SurfaceDim: p.Neutral.AbsToneUniform(6), - Surface: p.Neutral.AbsToneUniform(6), + Surface: p.Neutral.AbsToneUniform(0), SurfaceBright: p.Neutral.AbsToneUniform(24), SurfaceContainerLowest: p.Neutral.AbsToneUniform(4), From 8ca5bf1932554f5a5868a983b8232f01642eeb52 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Mon, 5 Jan 2026 16:08:21 -0800 Subject: [PATCH 98/99] content: MakeToolbarPDF function --- content/buttons.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/content/buttons.go b/content/buttons.go index d58a32c10e..b14f42c21b 100644 --- a/content/buttons.go +++ b/content/buttons.go @@ -17,6 +17,8 @@ import ( "cogentcore.org/core/tree" ) +// MakeToolbar adds the standard toolbar buttons for the content. +// See [Content.MakeToolbarPDF] for the optional PDF button. func (ct *Content) MakeToolbar(p *tree.Plan) { if false && ct.SizeClass() == core.SizeCompact { // TODO: implement hamburger menu for compact tree.Add(p, func(w *core.Button) { @@ -74,6 +76,10 @@ func (ct *Content) MakeToolbar(p *tree.Plan) { ct.Scene.MenuSearchDialog("Search", "Search "+core.TheApp.Name()) }) }) +} + +// MakeToolbarPDF adds the PDF button to the toolbar. This is optional. +func (ct *Content) MakeToolbarPDF(p *tree.Plan) { tree.Add(p, func(w *core.Button) { w.SetText("PDF").SetIcon(icons.PictureAsPdf).SetTooltip("PDF generates and opens / downloads the current page as a printable PDF file. See the Settings/Printer panel (Command+,) for settings.") w.OnClick(func(e events.Event) { From cec4708ffef5c819266e7f93e1bee4251e272119 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Mon, 5 Jan 2026 16:17:28 -0800 Subject: [PATCH 99/99] matcolor: scheme fixes --- colors/matcolor/scheme.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/colors/matcolor/scheme.go b/colors/matcolor/scheme.go index 1976ee5094..5ff3368870 100644 --- a/colors/matcolor/scheme.go +++ b/colors/matcolor/scheme.go @@ -112,7 +112,7 @@ func NewLightScheme(p *Palette) Scheme { SurfaceDim: p.Neutral.AbsToneUniform(87), Surface: p.Neutral.AbsToneUniform(100), - SurfaceBright: p.Neutral.AbsToneUniform(99), + SurfaceBright: p.Neutral.AbsToneUniform(100), SurfaceContainerLowest: p.Neutral.AbsToneUniform(100), SurfaceContainerLow: p.Neutral.AbsToneUniform(98), @@ -156,7 +156,7 @@ func NewDarkScheme(p *Palette) Scheme { Custom: map[string]Accent{}, SurfaceDim: p.Neutral.AbsToneUniform(6), - Surface: p.Neutral.AbsToneUniform(0), + Surface: p.Neutral.AbsToneUniform(6), SurfaceBright: p.Neutral.AbsToneUniform(24), SurfaceContainerLowest: p.Neutral.AbsToneUniform(4),