main.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: BSD-3-Clause
  4
  5package main
  6
  7import (
  8	"bytes"
  9	"embed"
 10	"fmt"
 11	"image"
 12	"image/color"
 13	"image/png"
 14	"io/ioutil"
 15	"os"
 16	"strings"
 17
 18	"gioui.org/font/opentype"
 19	"gioui.org/gpu/headless"
 20	"gioui.org/io/router"
 21	"gioui.org/layout"
 22	"gioui.org/op"
 23	"gioui.org/op/clip"
 24	"gioui.org/op/paint"
 25	"gioui.org/text"
 26	"gioui.org/unit"
 27	"gioui.org/widget/material"
 28	"github.com/adrg/frontmatter"
 29	"github.com/go-git/go-git/v5"
 30	flag "github.com/spf13/pflag"
 31	"gopkg.in/yaml.v3"
 32)
 33
 34var (
 35	flagHelp          *bool   = flag.BoolP("help", "h", false, "Show the help message")
 36	flagInput         *string = flag.StringP("input", "i", "", "Path to input Markdown")
 37	flagOutput        *string = flag.StringP("output", "o", "", "Path to output PNG")
 38	flagMetaSize      *int    = flag.IntP("metasize", "m", 40, "Size of font for meta information")
 39	flagPostTitleSize *int    = flag.IntP("posttitlesize", "p", 60, "Size of font for post title")
 40	flagSiteTitleSize *int    = flag.IntP("sitetitlesize", "s", 50, "Size of font for site title")
 41)
 42
 43//go:embed fonts
 44var fontEmbed embed.FS
 45
 46func main() {
 47	flag.Parse()
 48
 49	if *flagHelp {
 50		help()
 51		os.Exit(0)
 52	}
 53
 54	validateFlags()
 55
 56	postTitle, postDate, postContent := getPostInfo(*flagInput)
 57	postReadTime := getReadTime(postContent)
 58	siteTitle := getSiteTitle()
 59	dateEdited := getGitDate(*flagInput)
 60
 61	collection := fontCollection()
 62
 63	const scale = 1
 64	sz := image.Point{X: 1200 * scale, Y: 630 * scale}
 65
 66	w, err := headless.NewWindow(sz.X, sz.Y)
 67	if err != nil {
 68		panic(err)
 69	}
 70	th := material.NewTheme(collection)
 71	gtx := layout.Context{
 72		Ops:         new(op.Ops),
 73		Queue:       new(router.Router),
 74		Constraints: layout.Exact(sz),
 75		Metric: unit.Metric{
 76			PxPerDp: scale,
 77			PxPerSp: scale,
 78		},
 79	}
 80	paint.Fill(gtx.Ops, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
 81	layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
 82		gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .6)
 83		gtx.Constraints.Min.X = gtx.Constraints.Max.X
 84		gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
 85
 86		return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle, Spacing: layout.SpaceAround}.Layout(gtx,
 87
 88			layout.Rigid(layout.Spacer{Height: 50}.Layout),
 89
 90			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 91				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
 92				gtx.Constraints.Min.X = gtx.Constraints.Max.X
 93				title := material.Label(th, unit.Sp(float32(*flagPostTitleSize)), postTitle)
 94				title.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
 95				title.Alignment = text.Middle
 96				return title.Layout(gtx)
 97			}),
 98
 99			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
100				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .7)
101				gtx.Constraints.Min.X = gtx.Constraints.Max.X
102				return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
103					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
104						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
105							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
106								rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Reading Time: ")
107								rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
108								rTime.Alignment = text.Start
109								return rTime.Layout(gtx)
110							}),
111							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
112								rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postReadTime, " minutes"))
113								rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
114								rTime.Alignment = text.End
115								return rTime.Layout(gtx)
116							}),
117						)
118					}),
119
120					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
121						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
122							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
123								pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Published: ")
124								pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
125								pDate.Alignment = text.Start
126								return pDate.Layout(gtx)
127							}),
128							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
129								pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postDate))
130								pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
131								pDate.Alignment = text.End
132								return pDate.Layout(gtx)
133							}),
134						)
135					}),
136
137					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
138						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
139							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
140								eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Edited: ")
141								eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
142								eDate.Alignment = text.Start
143								return eDate.Layout(gtx)
144							}),
145							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
146								eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(dateEdited))
147								eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
148								eDate.Alignment = text.End
149								return eDate.Layout(gtx)
150							}),
151						)
152					}),
153				)
154			}),
155
156			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
157				size := image.Pt(gtx.Constraints.Max.X, 4)
158				defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
159				paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(gtx.Ops)
160				paint.PaintOp{}.Add(gtx.Ops)
161				return layout.Dimensions{Size: size}
162			}),
163
164			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
165				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
166				gtx.Constraints.Min.X = gtx.Constraints.Max.X
167				sTitle := material.Label(th, unit.Sp(float32(*flagSiteTitleSize)), siteTitle)
168				sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
169				sTitle.Alignment = text.Middle
170				return sTitle.Layout(gtx)
171			}),
172			layout.Rigid(layout.Spacer{Height: 50}.Layout),
173		)
174	})
175	w.Frame(gtx.Ops)
176	img := image.NewRGBA(image.Rectangle{Max: sz})
177	err = w.Screenshot(img)
178	if err != nil {
179		fmt.Println("Error: Could not transfer GUI to image")
180		fmt.Println(err)
181		os.Exit(1)
182	}
183	var buf bytes.Buffer
184	if err := png.Encode(&buf, img); err != nil {
185		fmt.Println("Error: Could not encode image to PNG")
186		fmt.Println(err)
187		os.Exit(1)
188	}
189	ioutil.WriteFile(*flagOutput, buf.Bytes(), 0o644)
190
191	os.Exit(0)
192}
193
194func fontCollection() []text.FontFace {
195	regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
196	if err != nil {
197		fmt.Println("Error: Could not read regular font")
198		fmt.Println(err)
199		os.Exit(1)
200	}
201	regularFace, err := opentype.Parse(regularFaceBytes)
202	if err != nil {
203		fmt.Println("Error: Could not parse regular font")
204		fmt.Println(err)
205		os.Exit(1)
206	}
207
208	boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
209	if err != nil {
210		fmt.Println("Error: Could not read bold font")
211		fmt.Println(err)
212		os.Exit(1)
213	}
214	boldFace, err := opentype.Parse(boldFaceBytes)
215	if err != nil {
216		fmt.Println("Error: Could not parse bold font")
217		fmt.Println(err)
218		os.Exit(1)
219	}
220
221	italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
222	if err != nil {
223		fmt.Println("Error: Could not read italic font")
224		fmt.Println(err)
225		os.Exit(1)
226	}
227	italicFace, err := opentype.Parse(italicFaceBytes)
228	if err != nil {
229		fmt.Println("Error: Could not parse italic font")
230		fmt.Println(err)
231		os.Exit(1)
232	}
233
234	boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
235	if err != nil {
236		fmt.Println("Error: Could not read bold italic font")
237		fmt.Println(err)
238		os.Exit(1)
239	}
240	boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
241	if err != nil {
242		fmt.Println("Error: Could not parse bold italic font")
243		fmt.Println(err)
244		os.Exit(1)
245	}
246
247	return []text.FontFace{
248		{
249			Font: text.Font{
250				Typeface: "Primary font",
251				Variant:  "",
252				Style:    text.Regular,
253				Weight:   text.Normal,
254			},
255			Face: regularFace,
256		},
257		{
258			Font: text.Font{
259				Typeface: "Primary font",
260				Variant:  "",
261				Style:    text.Regular,
262				Weight:   text.Bold,
263			},
264			Face: boldFace,
265		},
266		{
267			Font: text.Font{
268				Typeface: "Primary font",
269				Variant:  "",
270				Style:    text.Italic,
271				Weight:   text.Normal,
272			},
273			Face: italicFace,
274		},
275		{
276			Font: text.Font{
277				Typeface: "Primary font",
278				Variant:  "",
279				Style:    text.Italic,
280				Weight:   text.Bold,
281			},
282			Face: boldItalicFace,
283		},
284	}
285}
286
287// Print help message
288func help() {
289	fmt.Println("\nUsage: p2c [options]")
290	fmt.Println("\nOptions:")
291	flag.PrintDefaults()
292	fmt.Print(`
293example: p2c -i input.md -o output.png
294
295p2c is meant for use with Hugo.
296
297It looks at...
298- The Markdown file's frontmatter fields for
299  - title
300  - date
301- The site's config.{toml/yaml/yml} fields for
302  - title (site title)
303- The git history to determine the date the post was last edited.
304
305`)
306}
307
308// Validate flags
309func validateFlags() {
310	if *flagInput == "" {
311		fmt.Println("Error: No input file specified")
312		os.Exit(1)
313	}
314	if *flagOutput == "" {
315		fmt.Println("Error: No output file specified")
316		os.Exit(1)
317	}
318}
319
320// Get the post's title, subtitle, and date
321func getPostInfo(input string) (string, string, string) {
322	if _, err := os.Stat(input); os.IsNotExist(err) {
323		fmt.Println("Error: Input file does not exist")
324		fmt.Println(err)
325		os.Exit(1)
326	}
327
328	fileContents, err := os.ReadFile(input)
329	if err != nil {
330		fmt.Println("Error: Could not read input file")
331		fmt.Println(err)
332		os.Exit(1)
333	}
334
335	var fm struct {
336		Title string `yaml:"title"`
337		Date  string `yaml:"date"`
338	}
339
340	content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
341	if err != nil {
342		fmt.Println("Error: Could not parse frontmatter")
343		fmt.Println(err)
344		os.Exit(1)
345	}
346
347	fm.Date = strings.Split(fm.Date, "T")[0]
348
349	return fm.Title, fm.Date, string(content)
350}
351
352// Get the read time of the post
353func getReadTime(content string) int {
354	wordCount := len(strings.Fields(content))
355	return wordCount / 200
356}
357
358// Get the site's title
359func getSiteTitle() string {
360	validConf := ""
361	confs := []string{"config.toml", "config.yaml", "config.yml"}
362	for _, conf := range confs {
363		if _, err := os.Stat(conf); os.IsNotExist(err) {
364			continue
365		}
366		validConf = conf
367	}
368	if validConf == "" {
369		fmt.Println("Error: No valid config file found, please run this in the root of a Hugo site")
370		os.Exit(1)
371	}
372
373	var t struct {
374		Title string `yaml:"title"`
375	}
376
377	f, err := os.Open(validConf)
378	if err != nil {
379		fmt.Println("Error: Could not open config file")
380		fmt.Println(err)
381		os.Exit(1)
382	}
383	defer f.Close()
384
385	decoder := yaml.NewDecoder(f)
386	err = decoder.Decode(&t)
387	if err != nil {
388		fmt.Println("Error: Could not parse config file")
389		fmt.Println(err)
390		os.Exit(1)
391	}
392
393	return t.Title
394}
395
396// Get the date the post was last edited
397func getGitDate(input string) string {
398	if _, err := os.Stat(input); os.IsNotExist(err) {
399		fmt.Println("Error: Input file does not exist")
400		os.Exit(1)
401	}
402
403	repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
404	if err != nil {
405		fmt.Println("Error: Could not open git repository")
406		fmt.Println(err)
407		os.Exit(1)
408	}
409
410	commitIter, err := repo.Log(&git.LogOptions{FileName: &input})
411	if err != nil {
412		fmt.Println("Error: Could not get git history")
413		fmt.Println(err)
414		os.Exit(1)
415	}
416
417	commit, err := commitIter.Next()
418	if err != nil {
419		fmt.Println("Error: Could not get git history")
420		fmt.Println(err)
421		os.Exit(1)
422	}
423
424	return commit.Committer.When.Format("2006-01-02")
425}