1#!/usr/bin/env ruby
2require 'tk'
3
4begin
5  # try to use Img extension
6  require 'tkextlib/tkimg'
7rescue Exception
8  # cannot use Img extention --> ignore
9end
10
11
12############################
13# scrolled_canvas
14class TkScrolledCanvas < TkCanvas
15  include TkComposite
16
17  def initialize_composite(keys={})
18    @h_scr = TkScrollbar.new(@frame)
19    @v_scr = TkScrollbar.new(@frame)
20
21    @canvas = TkCanvas.new(@frame)
22    @path = @canvas.path
23
24    @canvas.xscrollbar(@h_scr)
25    @canvas.yscrollbar(@v_scr)
26
27    TkGrid.rowconfigure(@frame, 0, :weight=>1, :minsize=>0)
28    TkGrid.columnconfigure(@frame, 0, :weight=>1, :minsize=>0)
29
30    @canvas.grid(:row=>0, :column=>0, :sticky=>'news')
31    @h_scr.grid(:row=>1, :column=>0, :sticky=>'ew')
32    @v_scr.grid(:row=>0, :column=>1, :sticky=>'ns')
33
34    delegate('DEFAULT', @canvas)
35    delegate('background', @canvas, @h_scr, @v_scr)
36    delegate('activebackground', @h_scr, @v_scr)
37    delegate('troughcolor', @h_scr, @v_scr)
38    delegate('repeatdelay', @h_scr, @v_scr)
39    delegate('repeatinterval', @h_scr, @v_scr)
40    delegate('borderwidth', @frame)
41    delegate('relief', @frame)
42
43    delegate_alias('canvasborderwidth', 'borderwidth', @canvas)
44    delegate_alias('canvasrelief', 'relief', @canvas)
45
46    delegate_alias('scrollbarborderwidth', 'borderwidth', @h_scr, @v_scr)
47    delegate_alias('scrollbarrelief', 'relief', @h_scr, @v_scr)
48
49    configure(keys) unless keys.empty?
50  end
51end
52
53############################
54class PhotoCanvas < TkScrolledCanvas
55
56USAGE = <<EOT
57--- WHAT IS ---
58You can write comments on the loaded image, and save it as a Postscipt
59file (original image file is not modified). Each comment is drawn as a
60set of an indicator circle, an arrow, and a memo text. See the following
61how to write comments.
62This can save the list of memo texts to another file. It may useful to
63search the saved Postscript file by the comments on them.
64This may not support multibyte characters (multibyte texts are broken on
65a Postscript file). It depends on features of canvas widgets of Tcl/Tk
66libraries linked your Ruby/Tk. If you use Tcl/Tk8.0-jp (Japanized Tcl/Tk),
67you can (possibly) get a Japanese Postscript file.
68
69--- BINDINGS ---
70* Button-1 : draw comments by following steps
71    1st - Set center of a indicator circle.
72    2nd - Set head position of an arrow.
73    3rd - Set tail position of an arrow, and show an entry box.
74          Input a memo text and hit 'Enter' key to entry the comment.
75
76* Button-2-drag : scroll the canvas
77
78* Button-3 : when drawing, cancel current drawing
79
80* Double-Button-3 : delete the clicked comment (text, arrow, and circle)
81EOT
82
83  def initialize(*args)
84    super(*args)
85
86    self.highlightthickness = 0
87    self.selectborderwidth = 0
88
89    @photo = TkPhotoImage.new
90    @img = TkcImage.new(self, 0, 0, :image=>@photo)
91
92    width  = self.width
93    height = self.height
94    @scr_region = [-width, -height, width, height]
95    self.scrollregion(@scr_region)
96    self.xview_moveto(0.25)
97    self.yview_moveto(0.25)
98
99    @col = 'red'
100    @font = 'Helvetica -12'
101
102    @memo_id_num = -1
103    @memo_id_head = 'memo_'
104    @memo_id_tag = nil
105    @overlap_d = 2
106
107    @state = TkVariable.new
108    @border = 2
109    @selectborder = 1
110    @delta = @border + @selectborder
111    @entry = TkEntry.new(self, :relief=>:ridge, :borderwidth=>@border,
112                         :selectborderwidth=>@selectborder,
113                         :highlightthickness=>0)
114    @entry.bind('Return'){@state.value = 0}
115
116    @mode = old_mode = 0
117
118    _state0()
119
120    bind('2', :x, :y){|x,y| scan_mark(x,y)}
121    bind('B2-Motion', :x, :y){|x,y| scan_dragto(x,y)}
122
123    bind('3'){
124      next if (old_mode = @mode) == 0
125      @items.each{|item| item.delete }
126      _state0()
127    }
128
129    bind('Double-3', :widget, :x, :y){|w, x, y|
130      next if old_mode != 0
131      x = w.canvasx(x)
132      y = w.canvasy(y)
133      tag = nil
134      w.find_overlapping(x - @overlap_d, y - @overlap_d,
135                         x + @overlap_d, y + @overlap_d).find{|item|
136        ! (item.tags.find{|name|
137             if name =~ /^(#{@memo_id_head}\d+)$/
138               tag = $1
139             end
140           }.empty?)
141      }
142      w.delete(tag) if tag
143    }
144  end
145
146  #-----------------------------------
147  private
148  def _state0() # init
149    @mode = 0
150
151    @memo_id_num += 1
152    @memo_id_tag = @memo_id_head + @memo_id_num.to_s
153
154    @target = nil
155    @items = []
156    @mark = [0, 0]
157    bind_remove('Motion')
158    bind('ButtonRelease-1', proc{|x,y| _state1(x,y)}, '%x', '%y')
159  end
160
161  def _state1(x,y) # set center
162    @mode = 1
163
164    @target = TkcOval.new(self,
165                          [canvasx(x), canvasy(y)], [canvasx(x), canvasy(y)],
166                          :outline=>@col, :width=>3, :tags=>[@memo_id_tag])
167    @items << @target
168    @mark = [x,y]
169
170    bind('Motion', proc{|x,y| _state2(x,y)}, '%x', '%y')
171    bind('ButtonRelease-1', proc{|x,y| _state3(x,y)}, '%x', '%y')
172  end
173
174  def _state2(x,y) # create circle
175    @mode = 2
176
177    r = Integer(Math.sqrt((x-@mark[0])**2 + (y-@mark[1])**2))
178    @target.coords([canvasx(@mark[0] - r), canvasy(@mark[1] - r)],
179                   [canvasx(@mark[0] + r), canvasy(@mark[1] + r)])
180  end
181
182  def _state3(x,y) # set line start
183    @mode = 3
184
185    @target = TkcLine.new(self,
186                          [canvasx(x), canvasy(y)], [canvasx(x), canvasy(y)],
187                          :arrow=>:first, :arrowshape=>[10, 14, 5],
188                          :fill=>@col, :tags=>[@memo_id_tag])
189    @items << @target
190    @mark = [x, y]
191
192    bind('Motion', proc{|x,y| _state4(x,y)}, '%x', '%y')
193    bind('ButtonRelease-1', proc{|x,y| _state5(x,y)}, '%x', '%y')
194  end
195
196  def _state4(x,y) # create line
197    @mode = 4
198
199    @target.coords([canvasx(@mark[0]), canvasy(@mark[1])],
200                   [canvasx(x), canvasy(y)])
201  end
202
203  def _state5(x,y) # set text
204    @mode = 5
205
206    if x - @mark[0] >= 0
207      justify = 'left'
208      dx = - @delta
209
210      if y - @mark[1] >= 0
211        anchor = 'nw'
212        dy = - @delta
213      else
214        anchor = 'sw'
215        dy = @delta
216      end
217    else
218      justify = 'right'
219      dx = @delta
220
221      if y - @mark[1] >= 0
222        anchor = 'ne'
223        dy = - @delta
224      else
225        anchor = 'se'
226        dy = @delta
227      end
228    end
229
230    bind_remove('Motion')
231
232    @entry.value = ''
233    @entry.configure(:justify=>justify, :font=>@font, :foreground=>@col)
234
235    ewin = TkcWindow.new(self, [canvasx(x)+dx, canvasy(y)+dy],
236                         :window=>@entry, :state=>:normal, :anchor=>anchor,
237                         :tags=>[@memo_id_tag])
238
239    @entry.focus
240    @entry.grab
241    @state.wait
242    @entry.grab_release
243
244    ewin.delete
245
246    @target = TkcText.new(self, [canvasx(x), canvasy(y)],
247                          :anchor=>anchor, :justify=>justify,
248                          :fill=>@col, :font=>@font, :text=>@entry.value,
249                          :tags=>[@memo_id_tag])
250
251    _state0()
252  end
253
254  #-----------------------------------
255  public
256  def load_photo(filename)
257    @photo.configure(:file=>filename)
258  end
259
260  def modified?
261    ! ((find_withtag('all') - [@img]).empty?)
262  end
263
264  def fig_erase
265    (find_withtag('all') - [@img]).each{|item| item.delete}
266  end
267
268  def reset_region
269    width = @photo.width
270    height = @photo.height
271
272    if width > @scr_region[2]
273      @scr_region[0] = -width
274      @scr_region[2] = width
275    end
276
277    if height > @scr_region[3]
278      @scr_region[1] = -height
279      @scr_region[3] = height
280    end
281
282    self.scrollregion(@scr_region)
283    self.xview_moveto(0.25)
284    self.yview_moveto(0.25)
285  end
286
287  def get_texts
288    ret = []
289    find_withtag('all').each{|item|
290      if item.kind_of?(TkcText)
291        ret << item[:text]
292      end
293    }
294    ret
295  end
296end
297############################
298
299# define methods for menu
300def open_file(canvas, fname)
301  if canvas.modified?
302    ret = Tk.messageBox(:icon=>'warning',:type=>'okcancel',:default=>'cancel',
303                        :message=>'Canvas may be modified. Realy erase? ')
304    return if ret == 'cancel'
305  end
306
307  filetypes = [
308    ['GIF Files', '.gif'],
309    ['GIF Files', [], 'GIFF'],
310    ['PPM Files', '.ppm'],
311    ['PGM Files', '.pgm']
312  ]
313
314  begin
315    if Tk::Img::package_version != ''
316      filetypes << ['JPEG Files', ['.jpg', '.jpeg']]
317      filetypes << ['PNG Files', '.png']
318      filetypes << ['PostScript Files', '.ps']
319      filetypes << ['PDF Files', '.pdf']
320      filetypes << ['Windows Bitmap Files', '.bmp']
321      filetypes << ['Windows Icon Files', '.ico']
322      filetypes << ['PCX Files', '.pcx']
323      filetypes << ['Pixmap Files', '.pixmap']
324      filetypes << ['SGI Files', '.sgi']
325      filetypes << ['Sun Raster Files', '.sun']
326      filetypes << ['TGA Files', '.tga']
327      filetypes << ['TIFF Files', '.tiff']
328      filetypes << ['XBM Files', '.xbm']
329      filetypes << ['XPM Files', '.xpm']
330    end
331  rescue
332  end
333
334  filetypes << ['ALL Files', '*']
335
336  fpath = Tk.getOpenFile(:filetypes=>filetypes)
337  return if fpath.empty?
338
339  begin
340    canvas.load_photo(fpath)
341  rescue => e
342    Tk.messageBox(:icon=>'error', :type=>'ok',
343                  :message=>"Fail to read '#{fpath}'.\n#{e.message}")
344  end
345
346  canvas.fig_erase
347  canvas.reset_region
348
349  fname.value = fpath
350end
351
352# --------------------------------
353def save_memo(canvas, fname)
354  initname = fname.value
355  if initname != '-'
356    initname = File.basename(initname, File.extname(initname))
357    fpath = Tk.getSaveFile(:filetypes=>[ ['Text Files', '.txt'],
358                                         ['ALL Files', '*'] ],
359                           :initialfile=>initname)
360  else
361    fpath = Tk.getSaveFile(:filetypes=>[ ['Text Files', '.txt'],
362                                         ['ALL Files', '*'] ])
363  end
364  return if fpath.empty?
365
366  begin
367    fid = open(fpath, 'w')
368  rescue => e
369    Tk.messageBox(:icon=>'error', :type=>'ok',
370                  :message=>"Fail to open '#{fname.value}'.\n#{e.message}")
371  end
372
373  begin
374    canvas.get_texts.each{|txt|
375      fid.print(txt, "\n")
376    }
377  ensure
378    fid.close
379  end
380end
381
382# --------------------------------
383def ps_print(canvas, fname)
384  initname = fname.value
385  if initname != '-'
386    initname = File.basename(initname, File.extname(initname))
387    fpath = Tk.getSaveFile(:filetypes=>[ ['Postscript Files', '.ps'],
388                                         ['ALL Files', '*'] ],
389                           :initialfile=>initname)
390  else
391    fpath = Tk.getSaveFile(:filetypes=>[ ['Postscript Files', '.ps'],
392                                         ['ALL Files', '*'] ])
393  end
394  return if fpath.empty?
395
396  bbox = canvas.bbox('all')
397  canvas.postscript(:file=>fpath, :x=>bbox[0], :y=>bbox[1],
398                    :width=>bbox[2] - bbox[0], :height=>bbox[3] - bbox[1])
399end
400
401# --------------------------------
402def quit(canvas)
403  ret = Tk.messageBox(:icon=>'warning', :type=>'okcancel',
404                      :default=>'cancel',
405                      :message=>'Realy quit? ')
406  exit if ret == 'ok'
407end
408
409# --------------------------------
410# setup root
411root = TkRoot.new(:title=>'Fig Memo')
412
413# create canvas frame
414canvas = PhotoCanvas.new(root).pack(:fill=>:both, :expand=>true)
415usage_frame = TkFrame.new(root, :relief=>:ridge, :borderwidth=>2)
416hide_btn = TkButton.new(usage_frame, :text=>'hide usage',
417                        :font=>{:size=>8}, :pady=>1,
418                        :command=>proc{usage_frame.unpack})
419hide_btn.pack(:anchor=>'e', :padx=>5)
420usage = TkLabel.new(usage_frame, :text=>PhotoCanvas::USAGE,
421                    :font=>'Helvetica 8', :justify=>:left).pack
422
423show_usage = proc{
424  usage_frame.pack(:before=>canvas, :fill=>:x, :expand=>true)
425}
426
427fname = TkVariable.new('-')
428f = TkFrame.new(root, :relief=>:sunken, :borderwidth=>1).pack(:fill=>:x)
429label = TkLabel.new(f, :textvariable=>fname,
430                    :font=>{:size=>-12, :weight=>:bold},
431                    :anchor=>'w').pack(:side=>:left, :fill=>:x, :padx=>10)
432
433# create menu
434mspec = [
435  [ ['File', 0],
436    ['Show Usage',      proc{show_usage.call}, 5],
437    '---',
438    ['Open Image File', proc{open_file(canvas, fname)}, 0],
439    ['Save Memo Texts', proc{save_memo(canvas, fname)}, 0],
440    '---',
441    ['Save Postscript', proc{ps_print(canvas, fname)}, 5],
442    '---',
443    ['Quit', proc{quit(canvas)}, 0]
444  ]
445]
446root.add_menubar(mspec)
447
448# manage wm_protocol
449root.protocol(:WM_DELETE_WINDOW){quit(canvas)}
450
451# show usage
452show_usage.call
453
454# --------------------------------
455# start eventloop
456Tk.mainloop
457